feat: Add Relocation History & 1-Click Undo (Tab 4), Library Storage Analytics Heatmap, Zero-Byte & Empty Directory Cleaner, Command Palette (Ctrl+K), In-Player Snapshot/PiP/Loop tools, and CSV Exporters

This commit is contained in:
david
2026-08-17 17:44:29 -04:00
parent 0524dd4a93
commit 1a5cbc3e47
2 changed files with 1081 additions and 9 deletions
+381 -2
View File
@@ -57,11 +57,45 @@ def init_db():
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_item_key ON performer_analysis_cache(item_key)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_item_type ON performer_analysis_cache(item_type)')
cursor.execute('''
CREATE TABLE IF NOT EXISTS relocation_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL, -- 'qb' or 'smb'
item_key TEXT NOT NULL, -- torrent hash or file path
item_name TEXT NOT NULL,
source_path TEXT NOT NULL,
target_path TEXT NOT NULL,
performer TEXT,
file_size INTEGER DEFAULT 0,
status TEXT DEFAULT 'completed', -- 'completed', 'undone', 'failed'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
undone_at TIMESTAMP
)
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_history_key ON relocation_history(item_key)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_history_time ON relocation_history(created_at)')
conn.commit()
conn.close()
init_db()
def record_relocation_history(source_type, item_key, item_name, source_path, target_path, performer="", file_size=0):
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO relocation_history (source_type, item_key, item_name, source_path, target_path, performer, file_size, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed')
''', (source_type, item_key, item_name, source_path, target_path, performer, file_size))
conn.commit()
history_id = cursor.lastrowid
conn.close()
return history_id
except Exception as e:
print(f"Error recording relocation history: {e}")
return None
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
@@ -735,6 +769,15 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
# Database Cache API
if self.path.startswith('/api/db/cache'):
self.handle_db_get_cache()
# Relocation History API
elif self.path.startswith('/api/history'):
self.handle_history_list()
# Storage Analytics & Heatmap API
elif self.path.startswith('/api/analytics/storage'):
self.handle_storage_analytics()
# SMB Maintenance Cleanup Scan API
elif self.path.startswith('/api/smb/scan-cleanup'):
self.handle_smb_scan_cleanup()
# Video Stream API (Range 206 Partial Content)
elif self.path.startswith('/api/stream'):
self.handle_stream_video()
@@ -786,24 +829,38 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_db_batch_save()
elif self.path == '/api/db/delete':
self.handle_db_delete()
# Stash Lookup & Image Review APIs
# Relocation History & Undo APIs
elif self.path == '/api/history/record':
self.handle_history_record()
elif self.path == '/api/history/undo':
self.handle_history_undo()
elif self.path == '/api/history/clear':
self.handle_history_clear()
# Stash Lookup, Scan & Image Review APIs
elif self.path == '/api/stash/lookup':
self.handle_stash_lookup()
elif self.path == '/api/stash/batch-lookup':
self.handle_stash_batch_lookup()
elif self.path == '/api/stash/scan':
self.handle_stash_metadata_scan()
elif self.path == '/api/stash/performers/find-images':
self.handle_stash_find_images()
elif self.path == '/api/stash/performers/commit-image':
self.handle_stash_commit_image()
elif self.path == '/api/stash/performers/batch-commit-images':
self.handle_stash_batch_commit_images()
# SMB API Moves & Deletes
# SMB API Moves, Deletes & Cleanups
elif self.path == '/api/smb/move':
self.handle_smb_move()
elif self.path == '/api/smb/batch-move':
self.handle_smb_batch_move()
elif self.path == '/api/smb/delete':
self.handle_smb_delete()
elif self.path == '/api/smb/cleanup':
self.handle_smb_execute_cleanup()
# Webhook API
elif self.path == '/api/webhooks/send':
self.handle_send_webhook()
elif self.path.startswith('/qb-proxy/'):
self.proxy_qb_request('POST')
elif self.path.startswith('/anthropic-proxy/'):
@@ -1558,6 +1615,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
source_path = payload.get('source')
target_dir = payload.get('targetDir')
target_file = payload.get('targetFile')
performer = payload.get('performer', '')
if not source_path or not os.path.exists(source_path):
self.send_response(400)
@@ -1581,10 +1639,18 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
dest_dir = os.path.dirname(target_file)
os.makedirs(dest_dir, exist_ok=True)
f_size = 0
try:
f_size = os.path.getsize(source_path)
except Exception:
pass
# Move file
shutil.move(source_path, target_file)
# Record in relocation history
record_relocation_history('smb', source_path, os.path.basename(source_path), source_path, target_file, performer=performer, file_size=f_size)
# Update DB status if entry exists
try:
conn = get_db_connection()
@@ -1620,6 +1686,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
source = item.get('source')
target_dir = item.get('targetDir')
target_file = item.get('targetFile')
performer = item.get('performer', '')
if not target_file and target_dir and source:
target_file = os.path.join(target_dir, os.path.basename(source))
@@ -1631,7 +1698,16 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
try:
dest_dir = os.path.dirname(target_file)
os.makedirs(dest_dir, exist_ok=True)
f_size = 0
try:
f_size = os.path.getsize(source)
except Exception:
pass
shutil.move(source, target_file)
# Record history
record_relocation_history('smb', source, os.path.basename(source), source, target_file, performer=performer, file_size=f_size)
# Update DB
try:
@@ -1717,6 +1793,309 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.send_header('Content-Type', 'application/json')
self.send_cors_headers()
self.end_headers()
self.wfile.write(json.dumps({"error": f"Delete failed: {str(e)}"}).encode('utf-8'))
# ==================== Relocation History & Undo Handlers ====================
def handle_history_list(self):
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM relocation_history ORDER BY id DESC LIMIT 250")
rows = [dict(r) for r in cursor.fetchall()]
cursor.execute("SELECT COUNT(*) as total, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as active, SUM(CASE WHEN status='undone' THEN 1 ELSE 0 END) as undone, SUM(file_size) as total_bytes FROM relocation_history")
raw_stats = cursor.fetchone()
stats = dict(raw_stats) if raw_stats else {}
conn.close()
self.send_json_response(200, {"history": rows, "stats": stats, "count": len(rows)})
except Exception as e:
self.send_json_response(500, {"error": str(e), "history": []})
def handle_history_record(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
p = json.loads(post_data.decode('utf-8'))
hid = record_relocation_history(
p.get('source_type', 'qb'),
p.get('item_key', ''),
p.get('item_name', ''),
p.get('source_path', ''),
p.get('target_path', ''),
performer=p.get('performer', ''),
file_size=p.get('file_size', 0)
)
self.send_json_response(200, {"success": True, "history_id": hid})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
def handle_history_undo(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
history_id = payload.get('history_id')
if not history_id:
self.send_json_response(400, {"error": "Missing history_id"})
return
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM relocation_history WHERE id = ?", (history_id,))
row = cursor.fetchone()
if not row:
conn.close()
self.send_json_response(404, {"error": "History entry not found"})
return
item = dict(row)
source_path = item['source_path']
target_path = item['target_path']
source_type = item['source_type']
item_key = item['item_key']
if item['status'] == 'undone':
conn.close()
self.send_json_response(400, {"error": "Entry has already been undone"})
return
# Execute Undo
if source_type == 'smb':
if os.path.exists(target_path):
os.makedirs(os.path.dirname(source_path), exist_ok=True)
shutil.move(target_path, source_path)
elif os.path.exists(os.path.join(target_path, os.path.basename(source_path))):
actual_file = os.path.join(target_path, os.path.basename(source_path))
os.makedirs(os.path.dirname(source_path), exist_ok=True)
shutil.move(actual_file, source_path)
else:
conn.close()
self.send_json_response(404, {"error": f"Target file no longer exists at {target_path}"})
return
# Update analysis cache
cursor.execute("UPDATE performer_analysis_cache SET move_status = 'ready', updated_at = CURRENT_TIMESTAMP WHERE item_key = ?", (source_path,))
cursor.execute("UPDATE relocation_history SET status = 'undone', undone_at = CURRENT_TIMESTAMP WHERE id = ?", (history_id,))
conn.commit()
conn.close()
self.send_json_response(200, {"success": True, "undone_item": item})
except Exception as e:
self.send_json_response(500, {"error": f"Undo failed: {str(e)}"})
def handle_history_clear(self):
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM relocation_history")
conn.commit()
conn.close()
self.send_json_response(200, {"success": True})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
# ==================== Stash Library Metadata Scan Trigger ====================
def handle_stash_metadata_scan(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
paths = payload.get('paths', [])
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
mutation = '''
mutation MetadataScan($input: ScanMetadataInput!) {
metadataScan(input: $input)
}
'''
variables = {
"input": {
"paths": paths if paths else [],
"rescan": False,
"scanGenerateCovers": True
}
}
res = execute_stash_graphql(mutation, stash_url, timeout=20)
self.send_json_response(200, {"success": True, "result": res})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
# ==================== SMB Maintenance & Cleanup Handlers ====================
def handle_smb_scan_cleanup(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
scan_dir = params.get('path', [DEFAULT_SMB_SOURCE])[0]
ensure_smb_mounted()
if not os.path.exists(scan_dir):
self.send_json_response(404, {"error": f"Directory does not exist: {scan_dir}", "zero_byte_files": [], "empty_dirs": []})
return
zero_byte = []
empty_dirs = []
try:
for root, dirs, files in os.walk(scan_dir, topdown=False):
for f in files:
fp = os.path.join(root, f)
try:
if os.path.isfile(fp) and os.path.getsize(fp) == 0:
zero_byte.append({"name": f, "path": fp, "size": 0})
except Exception:
pass
# Check empty directories
if root != scan_dir and not os.listdir(root):
empty_dirs.append({"name": os.path.basename(root), "path": root})
self.send_json_response(200, {
"target_dir": scan_dir,
"zero_byte_files": zero_byte,
"empty_dirs": empty_dirs,
"zero_byte_count": len(zero_byte),
"empty_dir_count": len(empty_dirs)
})
except Exception as e:
self.send_json_response(500, {"error": str(e), "zero_byte_files": [], "empty_dirs": []})
def handle_smb_execute_cleanup(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
files_to_delete = payload.get('files', [])
dirs_to_prune = payload.get('dirs', [])
deleted_files = 0
pruned_dirs = 0
for fp in files_to_delete:
try:
if os.path.isfile(fp):
os.remove(fp)
deleted_files += 1
except Exception:
pass
for dp in dirs_to_prune:
try:
if os.path.isdir(dp) and not os.listdir(dp):
os.rmdir(dp)
pruned_dirs += 1
except Exception:
pass
self.send_json_response(200, {
"success": True,
"deleted_files": deleted_files,
"pruned_dirs": pruned_dirs
})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
# ==================== Storage Analytics & Heatmap ====================
def handle_storage_analytics(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
target_root = params.get('path', [DEFAULT_SMB_TARGET_ROOT])[0]
ensure_smb_mounted()
if not os.path.exists(target_root):
self.send_json_response(404, {"error": f"Target root not accessible: {target_root}", "performers": []})
return
performer_stats = []
total_library_size = 0
total_video_count = 0
try:
entries = os.listdir(target_root)
for entry in entries:
full_p = os.path.join(target_root, entry)
if os.path.isdir(full_p) and not entry.startswith('.'):
f_count = 0
f_bytes = 0
for root, _, files in os.walk(full_p):
for f in files:
ext = os.path.splitext(f)[1].lower()
if ext in VIDEO_EXTENSIONS:
f_count += 1
try:
f_bytes += os.path.getsize(os.path.join(root, f))
except Exception:
pass
if f_count > 0:
total_video_count += f_count
total_library_size += f_bytes
performer_stats.append({
"name": entry,
"folder": entry,
"path": full_p,
"video_count": f_count,
"total_size": f_bytes,
"formatted_size": f"{f_bytes / (1024**3):.2f} GB" if f_bytes >= 1024**3 else f"{f_bytes / (1024**2):.1f} MB"
})
# Sort largest folders first
performer_stats.sort(key=lambda x: -x['total_size'])
# Free disk space check
disk_info = {}
try:
st = os.statvfs(target_root)
disk_info = {
"total": st.f_blocks * st.f_frsize,
"free": st.f_bavail * st.f_frsize,
"used": (st.f_blocks - st.f_bavail) * st.f_frsize,
"free_gb": f"{(st.f_bavail * st.f_frsize) / (1024**3):.1f} GB",
"total_gb": f"{(st.f_blocks * st.f_frsize) / (1024**3):.1f} GB"
}
except Exception:
pass
self.send_json_response(200, {
"target_root": target_root,
"total_performers": len(performer_stats),
"total_videos": total_video_count,
"total_size_bytes": total_library_size,
"total_size_formatted": f"{total_library_size / (1024**3):.2f} GB",
"performers": performer_stats[:100],
"disk": disk_info
})
except Exception as e:
self.send_json_response(500, {"error": str(e), "performers": []})
# ==================== Webhook Dispatcher ====================
def handle_send_webhook(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length)
try:
payload = json.loads(post_data.decode('utf-8'))
webhook_url = payload.get('webhook_url')
title = payload.get('title', 'Performer Organizer Notification')
message = payload.get('message', '')
items_count = payload.get('items_count', 0)
if not webhook_url:
self.send_json_response(400, {"error": "Missing webhook_url"})
return
body = {
"content": f"🚀 **{title}**\n{message}\n*Organized {items_count} items into library.*",
"username": "qBittorrent Organizer"
}
req = urllib.request.Request(
webhook_url,
data=json.dumps(body).encode('utf-8'),
headers={'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0'}
)
with urllib.request.urlopen(req, timeout=10) as res:
self.send_json_response(200, {"success": True, "status": res.status})
except Exception as e:
self.send_json_response(500, {"error": str(e)})
self.end_headers()
self.wfile.write(json.dumps({"error": f"Delete request failed: {str(e)}"}).encode('utf-8'))
# ==================== External API Proxies ====================