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
bunkr_api/inspector.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sqlite3
|
|
3
|
+
import time
|
|
4
|
+
from contextlib import closing
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.prompt import Confirm, Prompt
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
# import from other modules
|
|
12
|
+
from .core.db import DatabaseManager
|
|
13
|
+
from .utils.formatting import format_bytes, parse_selection
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
class Inspector:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.db = DatabaseManager()
|
|
20
|
+
|
|
21
|
+
def get_conn(self):
|
|
22
|
+
return self.db._get_connection()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
##READ-ONLY REPORTS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def display_table(self, table_name, limit=10, search=None, show_all=False):
|
|
29
|
+
|
|
30
|
+
with closing(self.get_conn()) as conn:
|
|
31
|
+
query = f"SELECT * FROM {table_name}"
|
|
32
|
+
clauses, params = [], []
|
|
33
|
+
if search:
|
|
34
|
+
clauses.append("title LIKE ?")
|
|
35
|
+
params.append(f"%{search}%")
|
|
36
|
+
if clauses:
|
|
37
|
+
query += " WHERE " + " AND ".join(clauses)
|
|
38
|
+
if not show_all:
|
|
39
|
+
query += " LIMIT ?"
|
|
40
|
+
params.append(limit)
|
|
41
|
+
query += ";"
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
rows = conn.execute(query, params).fetchall()
|
|
45
|
+
except sqlite3.OperationalError as e:
|
|
46
|
+
console.print(f"[red]Query error: {e}[/red]")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not rows:
|
|
50
|
+
console.print(f"[yellow]No records found in {table_name}.[/yellow]")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
console.print(f"[bold white]{len(rows)} row(s):[/bold white]")
|
|
54
|
+
for row in rows:
|
|
55
|
+
console.print(f"[bold cyan]--- id={row['id']} ---[/bold cyan]")
|
|
56
|
+
for key, value in dict(row).items():
|
|
57
|
+
console.print(f" [dim]{key}:[/dim] {value}", soft_wrap=True, highlight=False)
|
|
58
|
+
console.print()
|
|
59
|
+
|
|
60
|
+
def display_dashboard(self):
|
|
61
|
+
"""Zero-args default: Global volume and metrics overview."""
|
|
62
|
+
with closing(self.get_conn()) as conn:
|
|
63
|
+
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';").fetchall()]
|
|
64
|
+
|
|
65
|
+
summary = Table(title="[magenta]Database Volume & Pipeline Summary[/magenta]", expand=True, style="dim")
|
|
66
|
+
summary.add_column("Table Name", style="cyan")
|
|
67
|
+
summary.add_column("Total Records", justify="right", style="magenta")
|
|
68
|
+
summary.add_column("Pipeline Execution Metrics", style="green")
|
|
69
|
+
|
|
70
|
+
def safe_count(sql):
|
|
71
|
+
try: return str(conn.execute(sql).fetchone()[0])
|
|
72
|
+
except Exception:
|
|
73
|
+
return "?"
|
|
74
|
+
|
|
75
|
+
for table in tables:
|
|
76
|
+
count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
77
|
+
metrics = "N/A"
|
|
78
|
+
|
|
79
|
+
if table == "assets":
|
|
80
|
+
c = safe_count("SELECT COUNT(*) FROM assets WHERE download_status='COMPLETED'")
|
|
81
|
+
f = safe_count("SELECT COUNT(*) FROM assets WHERE download_status='FAILED'")
|
|
82
|
+
s = safe_count("SELECT COUNT(*) FROM assets WHERE is_staged=1")
|
|
83
|
+
metrics = f"[green]Done: {c}[/green] | [red]Fail: {f}[/red] | [yellow]Staged: {s}[/yellow]"
|
|
84
|
+
|
|
85
|
+
elif table == "albums":
|
|
86
|
+
s = safe_count("SELECT COUNT(*) FROM albums WHERE is_staged=1")
|
|
87
|
+
metrics = f"[yellow]Staged Albums: {s}[/yellow]"
|
|
88
|
+
|
|
89
|
+
elif table == "system_config":
|
|
90
|
+
try:
|
|
91
|
+
kvs = conn.execute("SELECT config_key, config_value FROM system_config;").fetchall()
|
|
92
|
+
metrics = ", ".join([f"[bold white]{r['config_key']}[/bold white]={r['config_value']}" for r in kvs])
|
|
93
|
+
except sqlite3.OperationalError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
summary.add_row(table, str(count), metrics)
|
|
97
|
+
|
|
98
|
+
console.print(Panel(summary, border_style="dim magenta", title="[bold white]Media Tracker Management System[/bold white]"))
|
|
99
|
+
|
|
100
|
+
def display_albums(self):
|
|
101
|
+
"""Per-album breakdown table."""
|
|
102
|
+
with closing(self.get_conn()) as conn:
|
|
103
|
+
rows = conn.execute("SELECT * FROM albums ORDER BY id ASC;").fetchall()
|
|
104
|
+
if not rows:
|
|
105
|
+
console.print("[yellow]No albums cataloged.[/yellow]")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
t = Table(title="[magenta]Per-Album Breakdown[/magenta]", expand=True, style="dim")
|
|
109
|
+
t.add_column("ID", style="cyan", justify="right")
|
|
110
|
+
t.add_column("Title")
|
|
111
|
+
t.add_column("Files", justify="right")
|
|
112
|
+
t.add_column("Status", justify="right")
|
|
113
|
+
t.add_column("Staged", justify="right", style="green")
|
|
114
|
+
t.add_column("Size", justify="right", style="magenta")
|
|
115
|
+
|
|
116
|
+
for a in rows:
|
|
117
|
+
# Query metrics for specific album
|
|
118
|
+
stats = conn.execute("""
|
|
119
|
+
SELECT
|
|
120
|
+
COUNT(*),
|
|
121
|
+
SUM(CASE WHEN download_status='COMPLETED' THEN 1 ELSE 0 END),
|
|
122
|
+
SUM(CASE WHEN download_status='FAILED' THEN 1 ELSE 0 END),
|
|
123
|
+
SUM(is_staged)
|
|
124
|
+
FROM assets WHERE album_id=?
|
|
125
|
+
""", (a['id'],)).fetchone()
|
|
126
|
+
|
|
127
|
+
total, comp, fail, staged = stats
|
|
128
|
+
pct = f"{(comp/total*100):.0f}%" if total else "0%"
|
|
129
|
+
|
|
130
|
+
t.add_row(
|
|
131
|
+
str(a['id']),
|
|
132
|
+
a['title'],
|
|
133
|
+
str(total),
|
|
134
|
+
f"{pct} [red](!{fail})[/red]" if fail else pct,
|
|
135
|
+
str(staged or 0),
|
|
136
|
+
format_bytes(a['aggregate_size'])
|
|
137
|
+
)
|
|
138
|
+
console.print(t)
|
|
139
|
+
|
|
140
|
+
def display_album_detail(self, album_id):
|
|
141
|
+
"""Detailed dump of one album and its assets."""
|
|
142
|
+
with closing(self.get_conn()) as conn:
|
|
143
|
+
album = conn.execute("SELECT * FROM albums WHERE id=?", (album_id,)).fetchone()
|
|
144
|
+
if not album:
|
|
145
|
+
console.print(f"[red]Album {album_id} not found.[/red]")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
console.print(f"[bold magenta]Album Detail: #{album['id']}[/bold magenta]")
|
|
149
|
+
for k, v in dict(album).items():
|
|
150
|
+
console.print(f" [dim]{k}:[/dim] {v}", soft_wrap=True)
|
|
151
|
+
|
|
152
|
+
assets = conn.execute("SELECT * FROM assets WHERE album_id=? ORDER BY track_number ASC", (album_id,)).fetchall()
|
|
153
|
+
for r in assets:
|
|
154
|
+
console.print(f"\n[bold cyan]--- Asset ID: {r['id']} (Track {r['track_number']}) ---[/bold cyan]")
|
|
155
|
+
for k, v in dict(r).items():
|
|
156
|
+
console.print(f" [dim]{k}:[/dim] {v}", soft_wrap=True, highlight=False)
|
|
157
|
+
|
|
158
|
+
def display_expiring(self):
|
|
159
|
+
"""Report for assets requiring token renewal."""
|
|
160
|
+
assets = self.db.get_needs_refresh()
|
|
161
|
+
if not assets:
|
|
162
|
+
console.print("[green]All tokens are fresh.[/green]")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
t = Table(title="[dim]Expiring/Expired Tokens[/dim]", style="dim")
|
|
166
|
+
t.add_column("ID", justify="right")
|
|
167
|
+
t.add_column("Title")
|
|
168
|
+
t.add_column("Status", style="yellow")
|
|
169
|
+
for a in assets:
|
|
170
|
+
expiry = a['token_expiry_timestamp']
|
|
171
|
+
if not expiry:
|
|
172
|
+
status = "[dim]No Token[/dim]"
|
|
173
|
+
else:
|
|
174
|
+
rem = expiry - int(time.time())
|
|
175
|
+
status = f"Stale ({rem//60}m)" if rem > 0 else "[bold red]Expired[/bold red]"
|
|
176
|
+
t.add_row(str(a['id']), a['title'], status)
|
|
177
|
+
console.print(t)
|
|
178
|
+
|
|
179
|
+
def display_staged(self):
|
|
180
|
+
|
|
181
|
+
with closing(self.get_conn()) as conn:
|
|
182
|
+
staged_albums = conn.execute(
|
|
183
|
+
"SELECT id, title, file_count FROM albums WHERE is_staged=1 ORDER BY id ASC;"
|
|
184
|
+
).fetchall()
|
|
185
|
+
staged_by_album = conn.execute("""
|
|
186
|
+
SELECT a.album_id, al.title AS album_title,
|
|
187
|
+
COUNT(*) AS staged_count,
|
|
188
|
+
SUM(CASE WHEN a.download_status='COMPLETED' THEN 1 ELSE 0 END) AS comp,
|
|
189
|
+
SUM(CASE WHEN a.download_status='FAILED' THEN 1 ELSE 0 END) AS fail,
|
|
190
|
+
SUM(a.raw_size_bytes) AS total_size
|
|
191
|
+
FROM assets a
|
|
192
|
+
LEFT JOIN albums al ON a.album_id = al.id
|
|
193
|
+
WHERE a.is_staged = 1
|
|
194
|
+
GROUP BY a.album_id
|
|
195
|
+
ORDER BY al.title ASC;
|
|
196
|
+
""").fetchall()
|
|
197
|
+
|
|
198
|
+
if staged_albums:
|
|
199
|
+
t = Table(title="[magenta]Staged Albums (album-level flag)[/magenta]", expand=True, style="dim")
|
|
200
|
+
t.add_column("ID", justify="right", style="cyan")
|
|
201
|
+
t.add_column("Title")
|
|
202
|
+
t.add_column("Files", justify="right")
|
|
203
|
+
for a in staged_albums:
|
|
204
|
+
t.add_row(str(a["id"]), a["title"], str(a["file_count"]))
|
|
205
|
+
console.print(t)
|
|
206
|
+
else:
|
|
207
|
+
console.print("[dim]No albums flagged staged at the album level.[/dim]")
|
|
208
|
+
|
|
209
|
+
if staged_by_album:
|
|
210
|
+
t = Table(title="[magenta]Staged Assets by Album[/magenta]", expand=True, style="dim")
|
|
211
|
+
t.add_column("Album")
|
|
212
|
+
t.add_column("Staged Files", justify="right", style="cyan")
|
|
213
|
+
t.add_column("Completed", justify="right", style="green")
|
|
214
|
+
t.add_column("Failed", justify="right", style="red")
|
|
215
|
+
t.add_column("Size", justify="right", style="magenta")
|
|
216
|
+
total_staged = total_size = 0
|
|
217
|
+
for row in staged_by_album:
|
|
218
|
+
t.add_row(row["album_title"] or "—", str(row["staged_count"]),
|
|
219
|
+
str(row["comp"]), str(row["fail"]), format_bytes(row["total_size"] or 0))
|
|
220
|
+
total_staged += row["staged_count"]
|
|
221
|
+
total_size += row["total_size"] or 0
|
|
222
|
+
console.print(t)
|
|
223
|
+
console.print(f"[bold]Totals:[/bold] {total_staged} staged file(s) across "
|
|
224
|
+
f"{len(staged_by_album)} album(s) — {format_bytes(total_size)}")
|
|
225
|
+
else:
|
|
226
|
+
console.print("[dim]No staged assets.[/dim]")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
## SCHEMA MIGRATIONS
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def add_column(self, spec: str):
|
|
233
|
+
try:
|
|
234
|
+
table, column, col_type = spec.split(":")
|
|
235
|
+
except ValueError:
|
|
236
|
+
console.print("[red]Expected table:column:type, e.g. assets:true_file_id:INTEGER[/red]")
|
|
237
|
+
return
|
|
238
|
+
with closing(self.get_conn()) as conn:
|
|
239
|
+
try:
|
|
240
|
+
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type};")
|
|
241
|
+
conn.commit()
|
|
242
|
+
console.print(f"[green]Added '{column}' ({col_type}) to '{table}'.[/green]")
|
|
243
|
+
except sqlite3.OperationalError as e:
|
|
244
|
+
console.print(f"[yellow]Skipped — {e} (likely already exists).[/yellow]")
|
|
245
|
+
|
|
246
|
+
def drop_column(self, spec: str, force: bool = False):
|
|
247
|
+
try:
|
|
248
|
+
table, column = spec.split(":")
|
|
249
|
+
except ValueError:
|
|
250
|
+
console.print("[red]Expected table:column, e.g. assets:true_file_id[/red]")
|
|
251
|
+
return
|
|
252
|
+
if not force and not Confirm.ask(f"[bold red]Permanently DROP '{column}' from '{table}'?[/bold red]"):
|
|
253
|
+
return
|
|
254
|
+
with closing(self.get_conn()) as conn:
|
|
255
|
+
try:
|
|
256
|
+
conn.execute(f"ALTER TABLE {table} DROP COLUMN {column};")
|
|
257
|
+
conn.commit()
|
|
258
|
+
console.print(f"[green]Dropped '{column}' from '{table}'.[/green]")
|
|
259
|
+
except sqlite3.OperationalError as e:
|
|
260
|
+
console.print(f"[red]Failed: {e}[/red]")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
## WRITE OPERATIONS
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _resolve_bound(self, conn, table):
|
|
267
|
+
"""Shared MAX(id) bound for parse_selection; a real safety
|
|
268
|
+
for gaps from prior deletions. Cheap fallback of 1000
|
|
269
|
+
only matters for a genuinely empty table."""
|
|
270
|
+
return conn.execute(f"SELECT MAX(id) FROM {table}").fetchone()[0] or 1000
|
|
271
|
+
|
|
272
|
+
def toggle_staging(self, target, selection, state):
|
|
273
|
+
with closing(self.get_conn()) as conn:
|
|
274
|
+
try:
|
|
275
|
+
table = "albums" if target == "album" else "assets"
|
|
276
|
+
total_rows = self._resolve_bound(conn, table)
|
|
277
|
+
ids = parse_selection(selection, total_items=total_rows)
|
|
278
|
+
|
|
279
|
+
placeholders = ",".join("?" for _ in ids)
|
|
280
|
+
found_rows = conn.execute(
|
|
281
|
+
f"SELECT id, title FROM {table} WHERE id IN ({placeholders})", ids
|
|
282
|
+
).fetchall()
|
|
283
|
+
|
|
284
|
+
if not found_rows:
|
|
285
|
+
console.print(f"[yellow]None of the requested {target} id(s) exist: {ids}[/yellow]")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
found = [r["id"] for r in found_rows]
|
|
289
|
+
labels = [f"#{r['id']} \"{r['title']}\"" for r in found_rows]
|
|
290
|
+
found_placeholders = ",".join("?" for _ in found)
|
|
291
|
+
|
|
292
|
+
if target == "album":
|
|
293
|
+
cursor = conn.execute(f"UPDATE albums SET is_staged=? WHERE id IN ({found_placeholders})", [state, *found])
|
|
294
|
+
conn.execute(f"UPDATE assets SET is_staged=? WHERE album_id IN ({found_placeholders})", [state, *found])
|
|
295
|
+
else:
|
|
296
|
+
cursor = conn.execute(f"UPDATE assets SET is_staged=? WHERE id IN ({found_placeholders})", [state, *found])
|
|
297
|
+
|
|
298
|
+
conn.commit()
|
|
299
|
+
verb = "staged" if state else "unstaged"
|
|
300
|
+
|
|
301
|
+
console.print(f"[green]Successfully {verb} {cursor.rowcount} {target}(s):[/green]")
|
|
302
|
+
for label in labels:
|
|
303
|
+
console.print(f" [dim]-[/dim] {label}")
|
|
304
|
+
except (sqlite3.Error, ValueError, TypeError) as e:
|
|
305
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
306
|
+
|
|
307
|
+
def wipe(self, album_ids=None, force=False):
|
|
308
|
+
with closing(self.get_conn()) as conn:
|
|
309
|
+
|
|
310
|
+
if not album_ids or album_ids.strip().lower() == "all":
|
|
311
|
+
if not force and not Confirm.ask("[bold red]WIPE:[/bold red] Delete ALL albums and assets?"):
|
|
312
|
+
return
|
|
313
|
+
conn.execute("DELETE FROM assets")
|
|
314
|
+
conn.execute("DELETE FROM albums")
|
|
315
|
+
conn.commit()
|
|
316
|
+
conn.execute("VACUUM")
|
|
317
|
+
console.print("[green]Data wiped. Configuration preserved.[/green]")
|
|
318
|
+
else:
|
|
319
|
+
try:
|
|
320
|
+
total = self._resolve_bound(conn, "albums")
|
|
321
|
+
ids = list(parse_selection(album_ids, total_items=total))
|
|
322
|
+
if not ids:
|
|
323
|
+
console.print("[yellow]No valid album ids in selection.[/yellow]")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
found_rows = conn.execute(
|
|
327
|
+
f"SELECT id, title FROM albums WHERE id IN ({','.join('?' for _ in ids)})", ids
|
|
328
|
+
).fetchall()
|
|
329
|
+
if not found_rows:
|
|
330
|
+
console.print(f"[yellow]None of the requested album id(s) exist: {ids}[/yellow]")
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
found = [r["id"] for r in found_rows]
|
|
334
|
+
labels = [f"#{r['id']} \"{r['title']}\"" for r in found_rows]
|
|
335
|
+
|
|
336
|
+
if not force and not Confirm.ask(f"Delete album(s) {', '.join(labels)}?"):
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
placeholders = ",".join("?" for _ in found)
|
|
340
|
+
conn.execute(f"DELETE FROM assets WHERE album_id IN ({placeholders})", found)
|
|
341
|
+
cursor = conn.execute(f"DELETE FROM albums WHERE id IN ({placeholders})", found)
|
|
342
|
+
conn.commit()
|
|
343
|
+
# cursor.rowcount reflects only rows actually found above,
|
|
344
|
+
# not the raw (possibly-partially-invalid) selection —
|
|
345
|
+
# toggle_staging now follows this same pattern too.
|
|
346
|
+
console.print(f"[green]Deleted {cursor.rowcount} album(s):[/green]")
|
|
347
|
+
for label in labels:
|
|
348
|
+
console.print(f" [dim]-[/dim] {label}")
|
|
349
|
+
except sqlite3.Error as e:
|
|
350
|
+
console.print(f"[red]Wipe failed: {e}[/red]")
|
|
351
|
+
|
|
352
|
+
def nuke(self):
|
|
353
|
+
if Prompt.ask("[bold red]DANGER:[/bold red] This drops ALL tables. Type [bold]nuke[/bold] to proceed") == "nuke":
|
|
354
|
+
with closing(self.get_conn()) as conn:
|
|
355
|
+
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetchall()]
|
|
356
|
+
for t in tables: conn.execute(f"DROP TABLE {t}")
|
|
357
|
+
conn.execute("VACUUM")
|
|
358
|
+
console.print("[green]Database nuked. Schema will auto-rebuild on next use.[/green]")
|
|
359
|
+
|
|
360
|
+
def recount_file_counts(self):
|
|
361
|
+
"""Repairs albums.file_count AND albums.aggregate_size by recomputing
|
|
362
|
+
both directly from the assets table, fixing drift like an album whose
|
|
363
|
+
scrape returned 0 parsed files (e.g. extract_advanced_album_files()
|
|
364
|
+
failing to match a page's layout) while a stale size string from the
|
|
365
|
+
separate header-text parse stuck around, or any album whose assets
|
|
366
|
+
were added/removed without these columns being resynced.
|
|
367
|
+
|
|
368
|
+
aggregate_size is stored as a formatted string ("8.84 GB"), so unlike
|
|
369
|
+
file_count this can't be a single SQL UPDATE — it's summed per-album
|
|
370
|
+
via SQL, then formatted in Python with the same format_bytes() used
|
|
371
|
+
everywhere else, and written back row by row.
|
|
372
|
+
"""
|
|
373
|
+
with closing(self.get_conn()) as conn:
|
|
374
|
+
try:
|
|
375
|
+
conn.execute("""
|
|
376
|
+
UPDATE albums
|
|
377
|
+
SET file_count = (
|
|
378
|
+
SELECT COUNT(*) FROM assets WHERE assets.album_id = albums.id
|
|
379
|
+
)
|
|
380
|
+
""")
|
|
381
|
+
|
|
382
|
+
totals = conn.execute("""
|
|
383
|
+
SELECT albums.id AS album_id,
|
|
384
|
+
COALESCE(SUM(assets.raw_size_bytes), 0) AS total_bytes
|
|
385
|
+
FROM albums
|
|
386
|
+
LEFT JOIN assets ON assets.album_id = albums.id
|
|
387
|
+
GROUP BY albums.id
|
|
388
|
+
""").fetchall()
|
|
389
|
+
|
|
390
|
+
for row in totals:
|
|
391
|
+
conn.execute(
|
|
392
|
+
"UPDATE albums SET aggregate_size = ? WHERE id = ?",
|
|
393
|
+
(format_bytes(row["total_bytes"]), row["album_id"]),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
conn.commit()
|
|
397
|
+
console.print(
|
|
398
|
+
f"[green]Recounted file_count and aggregate_size for {len(totals)} album(s).[/green]"
|
|
399
|
+
)
|
|
400
|
+
except sqlite3.Error as e:
|
|
401
|
+
console.print(f"[red]Recount failed: {e}[/red]")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
## CLI ENTRY POINT
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main():
|
|
408
|
+
insp = Inspector()
|
|
409
|
+
parser = argparse.ArgumentParser(prog="bunkr-inspect", description="Bunkr DB Management Toolkit")
|
|
410
|
+
subparsers = parser.add_subparsers(dest="command", metavar="{view,stage,db}")
|
|
411
|
+
|
|
412
|
+
# VIEW COMMAND
|
|
413
|
+
view = subparsers.add_parser(
|
|
414
|
+
"view", help="View album & file details (bunkr-inspect view -h for more help)",
|
|
415
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
416
|
+
epilog=(
|
|
417
|
+
"examples:\n"
|
|
418
|
+
" bunkr-inspect view quick summary of database\n"
|
|
419
|
+
" bunkr-inspect view dashboard quick summary of database\n"
|
|
420
|
+
" bunkr-inspect view albums per-album summary of completion %, staged count, size\n"
|
|
421
|
+
" bunkr-inspect view expiring albums with tokens expired or expiring soon\n"
|
|
422
|
+
" bunkr-inspect view staged quick view of what is staged, grouped by album\n"
|
|
423
|
+
" bunkr-inspect view album --id 5 full detail for album 5 + all its files\n"
|
|
424
|
+
" bunkr-inspect view assets raw dump of the 'assets' (files) table (any table name also works)\n"
|
|
425
|
+
" bunkr-inspect view assets --all every file row, not just the first 10\n"
|
|
426
|
+
" bunkr-inspect view assets -n 25 first 25 files rows instead of the default 10\n"
|
|
427
|
+
" bunkr-inspect view assets --search foo only files whose title contains 'foo'\n"
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
view.add_argument(
|
|
431
|
+
"target", default="dashboard", nargs="?", metavar="TARGET",
|
|
432
|
+
help=(
|
|
433
|
+
"What to show. One of the named reports — dashboard, albums, "
|
|
434
|
+
"expiring, staged, album (requires --id) — or any other table "
|
|
435
|
+
"name (e.g. assets) for a raw row-by-row dump of that table. "
|
|
436
|
+
"Defaults to 'dashboard' if omitted."
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
view.add_argument("--id", type=int, metavar="ALBUM_ID",
|
|
440
|
+
help="Album id to show full detail for — only used with 'view album'")
|
|
441
|
+
view.add_argument("--limit", "-n", type=int, default=10, metavar="N",
|
|
442
|
+
help="Row limit for raw table dumps, e.g. 'view assets -n 25' (default 10, ignored with --all)")
|
|
443
|
+
view.add_argument("--all", action="store_true",
|
|
444
|
+
help="Raw table dumps only: show every row instead of the --limit cutoff")
|
|
445
|
+
view.add_argument("--search", "-s", metavar="TEXT",
|
|
446
|
+
help="Raw table dumps only: filter to rows whose title contains TEXT (case-sensitive LIKE match)")
|
|
447
|
+
|
|
448
|
+
# STAGE COMMAND
|
|
449
|
+
stage = subparsers.add_parser(
|
|
450
|
+
"stage", help="Stage (or unstage) albums or files(assets) for download/streaming (bunkr-inspect stage -h for more help)",
|
|
451
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
452
|
+
epilog=(
|
|
453
|
+
"examples:\n"
|
|
454
|
+
" bunkr-inspect stage album 5 stage album 5 (cascades to all its assets)\n"
|
|
455
|
+
" bunkr-inspect stage album 5 --off unstage album 5 (cascades too)\n"
|
|
456
|
+
" bunkr-inspect stage asset 14 stage a single file (asset) by id\n"
|
|
457
|
+
" bunkr-inspect stage asset 14,15,22 stage a comma-separated list of asset ids\n"
|
|
458
|
+
" bunkr-inspect stage asset 10-20 stage a range of asset ids, inclusive\n"
|
|
459
|
+
" bunkr-inspect stage asset all stage every asset in the database\n"
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
stage.add_argument(
|
|
463
|
+
"target", choices=["album", "asset"], metavar="{album,asset}",
|
|
464
|
+
help="Whether SELECTION refers to album ids or asset ids. Staging an album cascades to all its assets."
|
|
465
|
+
)
|
|
466
|
+
stage.add_argument(
|
|
467
|
+
"selection", metavar="SELECTION",
|
|
468
|
+
help=(
|
|
469
|
+
"Which id(s) to stage/unstage: a single id (14), a comma list "
|
|
470
|
+
"(14,15,22), a range (10-20), a mix of both (1-5,8,12-14), or "
|
|
471
|
+
"the literal word 'all'."
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
stage.add_argument("--off", action="store_true", help="Unstage instead of stage (default action is to stage)")
|
|
475
|
+
|
|
476
|
+
# DB COMMAND
|
|
477
|
+
db_cmd = subparsers.add_parser(
|
|
478
|
+
"db", help="DB maintenance operations: wipe, nuke, raw SQL, schema changes (bunkr-inspect db -h for more help)",
|
|
479
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
480
|
+
epilog=(
|
|
481
|
+
"examples:\n"
|
|
482
|
+
" bunkr-inspect db --wipe delete ALL albums+assets, keep config, prompts first\n"
|
|
483
|
+
" bunkr-inspect db --wipe 12,13 delete only albums 12 and 13 (and their assets)\n"
|
|
484
|
+
" bunkr-inspect db --wipe 7-9 -y same, but skip the confirmation prompt\n"
|
|
485
|
+
" bunkr-inspect db --nuke factory reset: drops every table, including config\n"
|
|
486
|
+
" bunkr-inspect db --exec \"DELETE FROM assets WHERE download_status='FAILED';\"\n"
|
|
487
|
+
" bunkr-inspect db --add-column assets:true_file_id:INTEGER\n"
|
|
488
|
+
" bunkr-inspect db --drop-column assets:true_file_id -y\n"
|
|
489
|
+
" bunkr-inspect db --recount repair albums.file_count + aggregate_size from actual assets\n"
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
db_cmd.add_argument("--wipe", nargs="?", const="all", default=None,
|
|
493
|
+
metavar="IDS", help="Wipe album(s) by id/comma-list/range, or leave blank ('--wipe' alone) for every album")
|
|
494
|
+
db_cmd.add_argument("--nuke", action="store_true", help="Factory reset — drops every table including system_config")
|
|
495
|
+
db_cmd.add_argument("--exec", metavar="SQL", help="Run an arbitrary write SQL statement, e.g. an UPDATE or DELETE")
|
|
496
|
+
db_cmd.add_argument("--add-column", metavar="TABLE:COLUMN:TYPE", help="e.g. assets:true_file_id:INTEGER")
|
|
497
|
+
db_cmd.add_argument("--drop-column", metavar="TABLE:COLUMN", help="e.g. assets:true_file_id")
|
|
498
|
+
db_cmd.add_argument("--recount", action="store_true", help="Repair albums.file_count and aggregate_size by recomputing both from actual assets")
|
|
499
|
+
db_cmd.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts (for --wipe/--nuke/--drop-column)")
|
|
500
|
+
|
|
501
|
+
args = parser.parse_args()
|
|
502
|
+
|
|
503
|
+
if args.command == "view":
|
|
504
|
+
if args.target == "dashboard": insp.display_dashboard()
|
|
505
|
+
elif args.target == "albums": insp.display_albums()
|
|
506
|
+
elif args.target == "expiring": insp.display_expiring()
|
|
507
|
+
elif args.target == "staged": insp.display_staged()
|
|
508
|
+
elif args.target == "album":
|
|
509
|
+
if not args.id: console.print("[red]Error: --id required[/red]"); return
|
|
510
|
+
insp.display_album_detail(args.id)
|
|
511
|
+
else:
|
|
512
|
+
# this is not a known keyword. it as a real table name.
|
|
513
|
+
insp.display_table(args.target, limit=args.limit, search=args.search, show_all=args.all)
|
|
514
|
+
|
|
515
|
+
elif args.command == "stage":
|
|
516
|
+
state = 0 if args.off else 1
|
|
517
|
+
insp.toggle_staging(args.target, args.selection, state)
|
|
518
|
+
|
|
519
|
+
elif args.command == "db":
|
|
520
|
+
if args.nuke: insp.nuke()
|
|
521
|
+
elif args.wipe is not None: insp.wipe(args.wipe, args.yes)
|
|
522
|
+
elif args.add_column: insp.add_column(args.add_column)
|
|
523
|
+
elif args.drop_column: insp.drop_column(args.drop_column, args.yes)
|
|
524
|
+
elif args.recount: insp.recount_file_counts()
|
|
525
|
+
elif args.exec:
|
|
526
|
+
with closing(insp.get_conn()) as conn:
|
|
527
|
+
res = conn.execute(args.exec)
|
|
528
|
+
conn.commit()
|
|
529
|
+
console.print(f"[green]Executed. Rows affected: {res.rowcount}[/green]")
|
|
530
|
+
else:
|
|
531
|
+
insp.display_dashboard()
|
|
532
|
+
|
|
533
|
+
if __name__ == "__main__":
|
|
534
|
+
main()
|
|
File without changes
|