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/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