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:
@@ -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 ====================
|
||||
|
||||
Reference in New Issue
Block a user