uploading all of the changes to implement web app

This commit is contained in:
2025-12-06 19:41:44 -05:00
parent a5f5c00bdd
commit a577551c05
9 changed files with 2536 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
Your project directory is /home/david/Documents/vscode/personal_development/stashtoolkit_webtop
In the subfolder, context all of our project history contextual information is available - /home/david/Documents/vscode/personal_development/stashtoolkit_webtop/context
Prepare yourself to understand where we left off. Continue to prepare to use the context directory for additions as we proceed through the project.
You are an exper web and system administrator and responsible for enhancing this web application. Your first additional step will be to move the directory /home/david/Documents/vscode/personal_development/instance and subfolders/subfiles containing the database to the correct project folder, /home/david/Documents/vscode/personal_development/stashtoolkit_webtop
All additional files and resources should always be nested in our project directory at /home/david/Documents/vscode/personal_development/stashtoolkit_webtop
+9
View File
@@ -0,0 +1,9 @@
# Stash Toolkit Notes
## Dependencies
### yt-dlp
The video downloader functionality requires `yt-dlp` to be installed and available in the system's PATH.
You can download it from the official repository: https://github.com/yt-dlp/yt-dlp
+764
View File
@@ -0,0 +1,764 @@
from flask import Flask, request, jsonify, render_template, send_file
import os
import sqlite3
from collections import defaultdict
import smbclient
import tempfile
import json
import asyncio
import time
import threading
import mimetypes
import io
import zipfile
import base64
import qbittorrentapi
import requests
from bs4 import BeautifulSoup
import subprocess
# --- Threading Lock ---
REMOTE_DB_LOCK = threading.Lock()
# --- App Initialization & Path Configuration ---
# Explicitly define paths relative to this file's location to avoid ambiguity.
# .../stashtoolkit_webtop/backend/app.py
APP_ROOT = os.path.dirname(os.path.abspath(__file__))
# .../stashtoolkit_webtop/instance/
INSTANCE_PATH = os.path.join(os.path.dirname(APP_ROOT), 'instance')
app = Flask(__name__, template_folder='../static')
# --- Database Setup ---
LOCAL_DB_PATH = "local_cache.db"
CONFIG_FILE = "config.json"
def get_local_db():
db_path = os.path.join(INSTANCE_PATH, LOCAL_DB_PATH)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def init_local_db():
"""Initializes and resets cache tables to ensure schema is correct."""
try:
os.makedirs(INSTANCE_PATH)
except OSError:
pass # Already exists
with get_local_db() as conn:
cursor = conn.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)")
cursor.execute("CREATE TABLE IF NOT EXISTS sync_metadata (key TEXT PRIMARY KEY, value TEXT)")
cursor.execute("""
CREATE TABLE IF NOT EXISTS scan_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_type TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL,
message TEXT,
log TEXT
)""")
# Check if log column exists and add it if not (for existing databases)
cursor.execute("PRAGMA table_info(scan_history)")
columns = [row['name'] for row in cursor.fetchall()]
if 'log' not in columns:
cursor.execute("ALTER TABLE scan_history ADD COLUMN log TEXT")
cursor.execute("""
CREATE TABLE IF NOT EXISTS duplicate_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
file_basename TEXT,
set_id INTEGER NOT NULL,
FOREIGN KEY (scan_id) REFERENCES scan_history (id) ON DELETE CASCADE
)""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS transcode_plan_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
original_path TEXT NOT NULL,
transcoded_path TEXT NOT NULL,
FOREIGN KEY (scan_id) REFERENCES scan_history (id) ON DELETE CASCADE
)""")
# Drop and recreate cache tables to apply schema changes cleanly
print("Rebuilding local cache tables to ensure schema is up-to-date.")
cursor.execute("DROP TABLE IF EXISTS scenes")
cursor.execute("DROP TABLE IF EXISTS files")
cursor.execute("DROP TABLE IF EXISTS paths")
cursor.execute("CREATE TABLE scenes (id INTEGER PRIMARY KEY, oshash TEXT)")
cursor.execute("CREATE TABLE files (scene_id INTEGER, path TEXT, basename TEXT, path_id INTEGER)")
cursor.execute("CREATE TABLE paths (id INTEGER PRIMARY KEY, path TEXT)")
conn.commit()
# --- Global Variables & Constants ---
credentials = {}
DB_PATH = "//servervm.local/main/appdata/stashapp/config/stash-go.sqlite"
VIDEO_PATH = "//truenas.local/isolation"
TRANSCODES_PATH = "//truenas.local/isolation/stashapp/generated/transcodes"
COOMER_PATH = "//truenas.local/isolation/coomer"
PATH_MAPPING_FROM = "/data/"
PATH_MAPPING_TO = "//truenas.local/isolation/videos/"
# --- Credential and Configuration ---
def load_credentials():
global credentials
with app.app_context(), get_local_db() as conn:
row = conn.execute("SELECT value FROM config WHERE key = 'credentials'").fetchone()
if row and row['value']:
credentials = json.loads(row['value'])
else:
# os.path.dirname(APP_ROOT) is .../stashtoolkit_webtop/
old_config_path = os.path.join(os.path.dirname(APP_ROOT), CONFIG_FILE)
if os.path.exists(old_config_path):
print("Migrating credentials from config.json...")
try:
with open(old_config_path, "r") as f:
credentials = json.load(f)
conn.execute("REPLACE INTO config (key, value) VALUES (?, ?)", ('credentials', json.dumps(credentials)))
conn.commit()
os.rename(old_config_path, old_config_path + ".migrated")
print("Successfully migrated credentials.")
except (json.JSONDecodeError, OSError) as e:
print(f"Error migrating from config.json: {e}")
credentials = {}
def get_smb_credentials(path):
server = path.split("/")[2].split('@')[-1]
return credentials.get(server, {})
def get_file_size_smb(path):
try:
creds = get_smb_credentials(path)
with smbclient.open_file(path, mode='rb', **creds) as f:
return f.seek(0, 2)
except Exception:
return -1
def log_to_scan(scan_id, message):
with app.app_context():
with get_local_db() as conn:
# Append message to log, creating a newline if log is not empty
conn.execute(
"UPDATE scan_history SET log = CASE WHEN log IS NULL OR log = '' THEN ? ELSE log || '\n' || ? END WHERE id = ?",
(message, message, scan_id)
)
conn.commit()
def get_qb_client():
if 'qbittorrent' in credentials:
creds = credentials['qbittorrent']
return qbittorrentapi.Client(
host=creds.get('host'),
port=creds.get('port'),
username=creds.get('username'),
password=creds.get('password')
)
return None
# --- Remote Database Sync ---
def sync_remote_db():
print("Background sync thread started.")
print("Sync thread trying to acquire DB lock...")
with REMOTE_DB_LOCK:
print("Sync thread acquired DB lock.")
try:
creds = get_smb_credentials(DB_PATH)
remote_stat = smbclient.stat(DB_PATH, **creds)
remote_mtime = remote_stat.st_mtime
except Exception as e:
print(f"Sync thread failed to check remote DB status: {e}")
return
with app.app_context(), get_local_db() as conn_local:
row = conn_local.execute("SELECT value FROM sync_metadata WHERE key = 'last_sync_mtime'").fetchone()
last_sync_mtime = float(row['value']) if (row and row['value']) else 0
if remote_mtime <= last_sync_mtime:
print("Local database is already up to date.")
return
print(f"Remote DB is newer. Syncing from {remote_mtime} > {last_sync_mtime}.")
temp_db_path = None
try:
with smbclient.open_file(DB_PATH, mode='rb', **creds) as smb_file:
with tempfile.NamedTemporaryFile(delete=False, suffix=".sqlite") as temp_db:
temp_db.write(smb_file.read())
temp_db_path = temp_db.name
with sqlite3.connect(temp_db_path) as conn_remote:
conn_local.execute("DELETE FROM scenes"); conn_local.execute("DELETE FROM files"); conn_local.execute("DELETE FROM paths")
cursor_remote_scenes = conn_remote.execute("SELECT id, oshash FROM scenes")
conn_local.executemany("INSERT INTO scenes (id, oshash) VALUES (?, ?)", cursor_remote_scenes)
cursor_remote_files = conn_remote.execute("SELECT scene_id, path, basename, path_id FROM files")
conn_local.executemany("INSERT INTO files (scene_id, path, basename, path_id) VALUES (?, ?, ?, ?)", cursor_remote_files)
cursor_remote_paths = conn_remote.execute("SELECT id, path FROM paths")
conn_local.executemany("INSERT INTO paths (id, path) VALUES (?, ?)", cursor_remote_paths)
conn_local.execute("REPLACE INTO sync_metadata (key, value) VALUES (?, ?)", ('last_sync_mtime', remote_mtime))
conn_local.commit()
print("Database sync completed successfully.")
except sqlite3.Error as e:
print(f"Error during database sync: {e}")
conn_local.rollback()
finally:
if temp_db_path and os.path.exists(temp_db_path): os.remove(temp_db_path)
print("Sync thread released DB lock.")
# --- Initialize App & Background Tasks ---
with app.app_context():
init_local_db()
load_credentials()
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
threading.Thread(target=sync_remote_db, daemon=True).start()
# --- API Endpoints ---
@app.route('/')
def index(): return render_template('index.html')
@app.route('/api/save_credentials', methods=['POST'])
def save_credentials():
data = request.get_json()
with app.app_context(), get_local_db() as conn:
conn.execute("REPLACE INTO config (key, value) VALUES (?, ?)", ('credentials', json.dumps(data)))
global credentials
credentials = data
return jsonify({"success": True})
@app.route('/api/get_credentials', methods=['GET'])
def get_credentials():
with app.app_context(), get_local_db() as conn:
row = conn.execute("SELECT value FROM config WHERE key = 'credentials'").fetchone()
if row and row['value']:
return jsonify({"credentials": json.loads(row['value'])})
return jsonify({"credentials": {}})
@app.route('/api/check_credentials', methods=['GET'])
def check_credentials():
with app.app_context(), get_local_db() as conn:
row = conn.execute("SELECT value FROM config WHERE key = 'credentials'").fetchone()
return jsonify({"has_credentials": bool(row and row['value'] and row['value'] != '{}')})
@app.route('/api/check_connections', methods=['POST'])
async def check_connections():
async def run_sync(func, *args): return await asyncio.to_thread(func, *args)
def check_db_connection():
try:
print("Health check trying to acquire DB lock...")
with REMOTE_DB_LOCK:
print("Health check acquired DB lock.")
creds = get_smb_credentials(DB_PATH)
with smbclient.open_file(DB_PATH, mode='rb', **creds) as smb_file:
header = smb_file.read(100)
print("Health check released DB lock.")
if not header.startswith(b'SQLite format 3\x00'):
return 'stash_db', {"success": False, "message": "File is not a valid SQLite3 database."}
return 'stash_db', {"success": True, "message": "Successfully connected to the database."}
except Exception as e:
print(f"Health check failed: {e}")
return 'stash_db', {"success": False, "message": str(e)}
def check_truenas_connection():
try:
creds = get_smb_credentials(VIDEO_PATH)
it = smbclient.scandir(VIDEO_PATH, **creds)
try: next(it)
except StopIteration: pass
return 'truenas', {"success": True, "message": "Successfully connected to truenas.local."}
except Exception as e:
return 'truenas', {"success": False, "message": str(e)}
def check_qb_connection():
try:
client = get_qb_client()
if not client:
return 'qbittorrent', {"success": False, "message": "qBittorrent credentials not set."}
client.auth_log_in()
return 'qbittorrent', {"success": True, "message": f"Connected to qBittorrent v{client.app.version}"}
except Exception as e:
return 'qbittorrent', {"success": False, "message": str(e)}
results = await asyncio.gather(run_sync(check_db_connection), run_sync(check_truenas_connection), run_sync(check_qb_connection))
results_dict = dict(results)
if 'stash_db' in results_dict:
results_dict['servervm'] = {"success": results_dict['stash_db']['success'], "message": results_dict['stash_db']['message']}
return jsonify(results_dict)
def do_find_duplicates_task(app_context, scan_id):
with app_context:
log_to_scan(scan_id, "Starting duplicate scan...")
try:
with get_local_db() as conn:
# Find oshash values that are associated with more than one scene
log_to_scan(scan_id, "Finding duplicate hashes...")
duplicate_oshashes_query = """
SELECT oshash FROM scenes
WHERE oshash IS NOT NULL AND oshash != ''
GROUP BY oshash
HAVING COUNT(id) > 1
"""
cursor = conn.execute(duplicate_oshashes_query)
duplicate_oshashes = [row['oshash'] for row in cursor.fetchall()]
if not duplicate_oshashes:
log_to_scan(scan_id, "No duplicate hashes found.")
conn.execute("UPDATE scan_history SET status = 'completed' WHERE id = ?", (scan_id,))
conn.commit()
log_to_scan(scan_id, "Scan completed.")
return
log_to_scan(scan_id, f"Found {len(duplicate_oshashes)} sets of duplicates. Fetching file details...")
set_id_counter = 0
for oshash in duplicate_oshashes:
set_id_counter += 1
files_query = """
SELECT p.path || '/' || f.path as full_path, f.basename
FROM files f
JOIN scenes s ON s.id = f.scene_id
JOIN paths p ON p.id = f.path_id
WHERE s.oshash = ?
"""
cursor = conn.execute(files_query, (oshash,))
files = cursor.fetchall()
log_to_scan(scan_id, f"Processing set {set_id_counter} with {len(files)} files...")
for file_row in files:
full_path_smb = file_row['full_path'].replace(PATH_MAPPING_FROM, PATH_MAPPING_TO)
size = get_file_size_smb(full_path_smb)
conn.execute(
"INSERT INTO duplicate_results (scan_id, file_path, file_size, file_basename, set_id) VALUES (?, ?, ?, ?, ?)",
(scan_id, full_path_smb, size, file_row['basename'], set_id_counter)
)
conn.execute("UPDATE scan_history SET status = 'completed' WHERE id = ?", (scan_id,))
conn.commit()
log_to_scan(scan_id, "Duplicate scan completed successfully.")
except Exception as e:
error_message = f"Error in duplicate scan: {e}"
log_to_scan(scan_id, error_message)
with app.app_context(), get_local_db() as conn:
conn.execute("UPDATE scan_history SET status = 'error', message = ? WHERE id = ?", (str(e), scan_id))
conn.commit()
@app.route('/api/find_duplicates', methods=['POST'])
def find_duplicates():
with get_local_db() as conn:
cursor = conn.execute("INSERT INTO scan_history (scan_type, status) VALUES (?, ?)", ('duplicates', 'in_progress'))
scan_id = cursor.lastrowid
threading.Thread(target=do_find_duplicates_task, args=(app.app_context(), scan_id), daemon=True).start()
return jsonify({"scan_id": scan_id})
def do_transcode_plan_task(app_context, scan_id):
with app_context:
log_to_scan(scan_id, "Starting transcode replacement plan...")
try:
log_to_scan(scan_id, "Finding transcoded files...")
creds = get_smb_credentials(TRANSCODES_PATH)
basenames = [os.path.splitext(name)[0] for name in smbclient.listdir(TRANSCODES_PATH, **creds)]
if not basenames:
log_to_scan(scan_id, "No transcoded files found.")
with get_local_db() as conn:
conn.execute("UPDATE scan_history SET status = 'completed' WHERE id = ?", (scan_id,))
conn.commit()
log_to_scan(scan_id, "Scan completed.")
return
log_to_scan(scan_id, f"Found {len(basenames)} transcoded files. Matching with original files in the database...")
with get_local_db() as conn:
placeholders = ",".join(['?'] * len(basenames))
query = f"SELECT p.path || '/' || f.path as full_path, f.path as f_path FROM scenes AS s JOIN files AS f ON s.id = f.scene_id JOIN paths AS p ON f.path_id = p.id WHERE s.oshash IN ({placeholders})"
rows = conn.execute(query, basenames).fetchall()
log_to_scan(scan_id, f"Found {len(rows)} matching original files. Generating plan...")
for row in rows:
original_path = row['full_path'].replace(PATH_MAPPING_FROM, PATH_MAPPING_TO)
transcoded_path = os.path.join(TRANSCODES_PATH, row['f_path'])
conn.execute("INSERT INTO transcode_plan_results (scan_id, original_path, transcoded_path) VALUES (?, ?, ?)",
(scan_id, original_path, transcoded_path))
conn.execute("UPDATE scan_history SET status = 'completed' WHERE id = ?", (scan_id,))
conn.commit()
log_to_scan(scan_id, "Transcode replacement plan completed successfully.")
except Exception as e:
error_message = f"Error in transcode plan: {e}"
log_to_scan(scan_id, error_message)
with app.app_context(), get_local_db() as conn:
conn.execute("UPDATE scan_history SET status = 'error', message = ? WHERE id = ?", (str(e), scan_id))
conn.commit()
@app.route('/api/transcode_replacement_plan', methods=['POST'])
def transcode_replacement_plan():
with get_local_db() as conn:
cursor = conn.execute("INSERT INTO scan_history (scan_type, status) VALUES (?, ?)", ('transcode_plan', 'in_progress'))
scan_id = cursor.lastrowid
threading.Thread(target=do_transcode_plan_task, args=(app.app_context(), scan_id), daemon=True).start()
return jsonify({"scan_id": scan_id})
@app.route('/api/scan_history', methods=['GET'])
def scan_history():
with get_local_db() as conn:
rows = conn.execute("SELECT id, scan_type, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp, status, message FROM scan_history ORDER BY timestamp DESC").fetchall()
return jsonify([dict(row) for row in rows])
@app.route('/api/scan_result/<int:scan_id>', methods=['GET'])
def scan_result(scan_id):
with get_local_db() as conn:
history = conn.execute("SELECT * FROM scan_history WHERE id = ?", (scan_id,)).fetchone()
if not history: return jsonify({"error": "Scan ID not found"}), 404
results = []
if history['scan_type'] == 'duplicates':
rows = conn.execute("SELECT * FROM duplicate_results WHERE scan_id = ? ORDER BY set_id", (scan_id,)).fetchall()
grouped_results = defaultdict(list)
for row in rows: grouped_results[row['set_id']].append(dict(row))
results = list(grouped_results.values())
elif history['scan_type'] == 'transcode_plan':
rows = conn.execute("SELECT * FROM transcode_plan_results WHERE scan_id = ?", (scan_id,)).fetchall()
results = [dict(row) for row in rows]
elif history['scan_type'] == 'coomer_download':
# No specific results to render for this type, just the log
pass
elif history['scan_type'] == 'video_download':
# No specific results to render for this type, just the log
pass
return jsonify({"scan_info": dict(history), "results": results})
@app.route('/api/scan_log/<int:scan_id>')
def scan_log(scan_id):
with get_local_db() as conn:
row = conn.execute("SELECT log FROM scan_history WHERE id = ?", (scan_id,)).fetchone()
if row:
return jsonify({"log": row['log'] or ""})
return jsonify({"log": ""})
@app.route('/api/delete_files', methods=['POST'])
def delete_files():
files_to_delete = request.get_json().get('files', [])
deleted_files, errors = [], []
for file_path in files_to_delete:
try:
creds = get_smb_credentials(file_path)
smbclient.remove(file_path, **creds)
deleted_files.append(file_path)
except Exception as e:
errors.append({"file": file_path, "error": str(e)})
return jsonify({"deleted": deleted_files, "errors": errors})
@app.route('/api/execute_transcode_replacement', methods=['POST'])
def execute_transcode_replacement():
replacement_plan = request.get_json().get('plan', [])
replaced_files, errors = [], []
for item in replacement_plan:
original_file, new_file = item.get('original'), item.get('transcoded')
if not original_file or not new_file: continue
try:
creds_orig = get_smb_credentials(original_file)
if smbclient.exists(original_file, **creds_orig):
smbclient.remove(original_file, **creds_orig)
creds_new = get_smb_credentials(new_file)
smbclient.rename(new_file, original_file, **creds_new)
replaced_files.append({"original": original_file, "new": new_file})
except Exception as e:
errors.append({"original": original_file, "new": new_file, "error": str(e)})
return jsonify({"replaced": replaced_files, "errors": errors})
def do_coomer_download_task(app_context, scan_id, url):
with app_context:
log_to_scan(scan_id, f"Starting download for {url}")
try:
artist_name = url.strip('/').split('/')[-1]
save_path = os.path.join(COOMER_PATH, artist_name)
log_to_scan(scan_id, f"Saving files to: {save_path}")
creds = get_smb_credentials(save_path)
if not smbclient.exists(save_path, **creds):
smbclient.makedirs(save_path, exist_ok=True, **creds)
log_to_scan(scan_id, "Fetching artist page...")
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'})
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
video_links = []
for post in soup.find_all('article'):
for link in post.find_all('a', href=True):
if link['href'].endswith('.mp4'):
video_links.append(link['href'])
if not video_links:
log_to_scan(scan_id, "No video files found on the page.")
else:
log_to_scan(scan_id, f"Found {len(video_links)} videos. Starting download...")
for video_url in video_links:
filename = video_url.split('/')[-1]
file_path = os.path.join(save_path, filename)
if smbclient.exists(file_path, **creds):
log_to_scan(scan_id, f"Skipping existing file: {filename}")
continue
log_to_scan(scan_id, f"Downloading {filename}...")
video_response = requests.get(video_url, stream=True, headers={'User-Agent': 'Mozilla/5.0'})
video_response.raise_for_status()
with smbclient.open_file(file_path, mode='wb', **creds) as f:
for chunk in video_response.iter_content(chunk_size=8192):
f.write(chunk)
log_to_scan(scan_id, f"Finished downloading {filename}")
with get_local_db() as conn:
conn.execute("UPDATE scan_history SET status = 'completed' WHERE id = ?", (scan_id,))
conn.commit()
log_to_scan(scan_id, "Coomer download completed successfully.")
except Exception as e:
error_message = f"Error during coomer download: {e}"
log_to_scan(scan_id, error_message)
with app.app_context(), get_local_db() as conn:
conn.execute("UPDATE scan_history SET status = 'error', message = ? WHERE id = ?", (str(e), scan_id))
conn.commit()
@app.route('/api/download_coomer', methods=['POST'])
def download_coomer():
url = request.get_json().get('url')
if not url:
return jsonify({"error": "URL is required"}), 400
with get_local_db() as conn:
cursor = conn.execute("INSERT INTO scan_history (scan_type, status) VALUES (?, ?)", ('coomer_download', 'in_progress'))
scan_id = cursor.lastrowid
threading.Thread(target=do_coomer_download_task, args=(app.app_context(), scan_id, url), daemon=True).start()
return jsonify({"scan_id": scan_id})
def do_video_download_task(app_context, scan_id, url, destination_path):
with app_context:
log_to_scan(scan_id, f"Starting video download for {url}")
try:
# Security check: Ensure destination is within the main VIDEO_PATH
creds = get_smb_credentials(destination_path)
common_path = os.path.commonpath([os.path.normpath(VIDEO_PATH), os.path.normpath(destination_path)])
if common_path != os.path.normpath(VIDEO_PATH):
raise ValueError("Destination path is outside the allowed base directory.")
if not smbclient.exists(destination_path, **creds):
smbclient.makedirs(destination_path, exist_ok=True, **creds)
log_to_scan(scan_id, "Destination path is valid. Starting yt-dlp...")
# We need to construct the command carefully.
# yt-dlp needs to write to a location the app can access.
# Let's download to a temporary local file first, then move it to SMB.
with tempfile.TemporaryDirectory() as temp_dir:
temp_file_path_template = os.path.join(temp_dir, '%(title)s.%(ext)s')
command = [
'yt-dlp',
'-o', temp_file_path_template,
'--no-overwrites',
'--progress',
'--verbose',
url
]
log_to_scan(scan_id, f"Executing command: {' '.join(command)}")
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True)
downloaded_file_name = None
for line in process.stdout:
log_to_scan(scan_id, line.strip())
# Try to find the downloaded file name from yt-dlp output
if '[download] Destination:' in line:
downloaded_file_name = os.path.basename(line.split('Destination:')[1].strip())
elif '[Merger]' in line and 'Merging formats into' in line:
# Example: [Merger] Merging formats into "/tmp/tmpxxxxx/Video Title.mp4"
path_part = line.split('Merging formats into "')[1].strip().rstrip('"')
downloaded_file_name = os.path.basename(path_part)
process.wait()
if process.returncode != 0:
raise RuntimeError(f"yt-dlp exited with error code {process.returncode}")
if not downloaded_file_name:
# If we couldn't find the name, find the first file in the temp dir
files_in_temp = os.listdir(temp_dir)
if not files_in_temp:
raise FileNotFoundError("yt-dlp finished but no file was found in the temporary directory.")
downloaded_file_name = files_in_temp[0]
local_file_path = os.path.join(temp_dir, downloaded_file_name)
smb_file_path = os.path.join(destination_path, downloaded_file_name)
log_to_scan(scan_id, f"Download complete. Moving '{downloaded_file_name}' to {smb_file_path}")
if smbclient.exists(smb_file_path, **creds):
log_to_scan(scan_id, f"File '{downloaded_file_name}' already exists at destination. Deleting local file.")
else:
with open(local_file_path, 'rb') as f_in, smbclient.open_file(smb_file_path, 'wb', **creds) as f_out:
f_out.write(f_in.read())
log_to_scan(scan_id, "Move to SMB share complete.")
with get_local_db() as conn:
conn.execute("UPDATE scan_history SET status = 'completed' WHERE id = ?", (scan_id,))
conn.commit()
log_to_scan(scan_id, "Video download process finished successfully.")
except Exception as e:
error_message = f"Error during video download: {e}"
log_to_scan(scan_id, error_message)
with app.app_context(), get_local_db() as conn:
conn.execute("UPDATE scan_history SET status = 'error', message = ? WHERE id = ?", (str(e), scan_id))
conn.commit()
@app.route('/api/download_video', methods=['POST'])
def download_video():
data = request.get_json()
url = data.get('url')
destination_path = data.get('destination_path')
if not url or not destination_path:
return jsonify({"error": "URL and destination_path are required"}), 400
with get_local_db() as conn:
cursor = conn.execute("INSERT INTO scan_history (scan_type, status) VALUES (?, ?)", ('video_download', 'in_progress'))
scan_id = cursor.lastrowid
threading.Thread(target=do_video_download_task, args=(app.app_context(), scan_id, url, destination_path), daemon=True).start()
return jsonify({"scan_id": scan_id})
@app.route('/api/browse', methods=['POST'])
def browse():
path = request.get_json().get('path', VIDEO_PATH)
try:
creds = get_smb_credentials(path)
items = [dict(name=e.name, path=e.path, is_dir=e.is_dir()) for e in smbclient.scandir(path, **creds)]
return jsonify(items)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/preview_cbz')
def preview_cbz():
path = request.args.get('path')
if not path:
return "Missing path parameter", 400
try:
creds = get_smb_credentials(path)
smb_file = smbclient.open_file(path, mode='rb', **creds)
cbz_buffer = io.BytesIO(smb_file.read())
image_urls = []
with zipfile.ZipFile(cbz_buffer) as zf:
# Sort the file list to ensure correct order
file_list = sorted(zf.namelist())
for filename in file_list:
# Check if the file is an image
mimetype = mimetypes.guess_type(filename)[0]
if mimetype and mimetype.startswith('image/'):
with zf.open(filename) as image_file:
image_data = image_file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
data_url = f"data:{mimetype};base64,{encoded_image}"
image_urls.append(data_url)
return jsonify(image_urls)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/preview')
def preview():
path = request.args.get('path')
if not path:
return "Missing path parameter", 400
try:
creds = get_smb_credentials(path)
# Open the file with smbclient
smb_file = smbclient.open_file(path, mode='rb', **creds)
# Read the file into a seekable buffer (important for video)
buffer = io.BytesIO(smb_file.read())
buffer.seek(0)
mimetype = mimetypes.guess_type(path)[0]
return send_file(
buffer,
mimetype=mimetype,
as_attachment=False
)
except Exception as e:
return str(e), 500
@app.route('/api/qb_torrents')
def qb_torrents():
try:
client = get_qb_client()
if not client:
return jsonify({"error": "qBittorrent not configured."}), 400
torrents = []
for torrent in client.torrents_info():
torrents.append({
"hash": torrent.hash,
"name": torrent.name,
"size": torrent.size,
"progress": torrent.progress,
"status": torrent.state,
"dlspeed": torrent.dlspeed,
"upspeed": torrent.upspeed,
"eta": torrent.eta,
})
return jsonify(torrents)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/qb_action', methods=['POST'])
def qb_action():
try:
client = get_qb_client()
if not client:
return jsonify({"error": "qBittorrent not configured."}), 400
data = request.get_json()
action = data.get('action')
hashes = data.get('hashes')
if not action or not hashes:
return jsonify({"error": "Missing action or hashes."}), 400
if action == 'pause':
client.torrents_pause(torrent_hashes=hashes)
elif action == 'resume':
client.torrents_resume(torrent_hashes=hashes)
elif action == 'delete':
client.torrents_delete(delete_files=True, torrent_hashes=hashes)
else:
return jsonify({"error": "Invalid action."}), 400
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
app.run(debug=True, port=5000, host='0.0.0.0')
+1
View File
@@ -0,0 +1 @@
{"truenas.local": {"username": "home", "password": "kifer"}, "servervm.local": {"username": "server", "password": "password1"}}
+176
View File
@@ -0,0 +1,176 @@
This is the Gemini CLI. We are setting up the context for our chat.
Today's date is Friday, December 5, 2025 (formatted according to the user's locale).
My operating system is: linux
The project's temporary directory is: /home/david/.gemini/tmp/f70fb40e3d78e385383d5595cf4e47b20c081d7ee85cbd28861ccda000d85f65
I'm currently working in the directory: /var/home/david/Documents/vscode/personal_development
Here is the folder structure of the current working directories:
Showing up to 200 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (200 items) was reached.
/var/home/david/Documents/vscode/personal_development/
├───config.json
├───ExposureReports.py
├───README.md
├───.git/...
├───AetherPunk/
├───Docker/
│ ├───Outline/
│ │ └───docker-compose.yaml
│ ├───torrents/
│ │ ├───glueton both 2 deluge.yml
│ │ ├───gluetun both
│ │ ├───cyberghost/
│ │ │ ├───.~lock.client.key#
│ │ │ ├───ca.crt
│ │ │ ├───client.crt
│ │ │ ├───client.key
│ │ │ ├───docker-compose.yml
│ │ │ └───openvpn.ovpn
│ │ └───deluge/
│ │ └───docker-compose.yml
│ └───whispersetup/
│ └───docker-compose.yml
├───nginx/
│ ├───deluge.datdamdavid.com.conf
│ ├───gitea
│ └───jellyseer.datdamdavid.com.conf
├───NIST Reports/
│ ├───nist_july2025.py
│ ├───nvd_2025.py
│ ├───nvd.py
│ └───readme.md
├───Python Course Materials/
│ ├───David Kifer-Updated.docx
│ ├───David Kifer.docx
│ ├───DKIFER_Week5_pg267Excercise12.docx
│ ├───pg291-292DiscussionExcercise1and2.docx
│ ├───pg294excercise4.docx
│ ├───pg295-296excercises11and12.docx
│ ├───Week2/
│ │ ├───pg29Excercise1.py
│ │ ├───pg29Exercise1.py
│ │ ├───pg58exercise10.py
│ │ └───pg84exercise5.py
│ ├───Week3/
│ │ ├───coursera lab.py
│ │ ├───pg263Excercise1.py
│ │ ├───pg264Excercise4.py
│ │ ├───pg265Excercise2.py
│ │ └───pythontest.py
│ ├───Week4/
│ │ ├───pg291-292DiscussionExcercise1and2.py
│ │ ├───pg294excercise4.py
│ │ ├───pg295-296excercises11and12.py
│ │ └───windowscolorexcercise.py
│ ├───week5/
│ │ ├───Pg267Excercise12.py
│ │ └───test.py
│ ├───Week6/
│ │ └───main.py
│ ├───Week7/
│ │ └───pg408Exercise15.py
│ └───Week8/
│ ├───DKIFER_Week8_lab_exception_handling.py
│ ├───DKIFER_Week8Lab_pg331_Excercise9.py
│ ├───input.txt
│ └───output.txt
├───stashtoolkit/
│ └───Transcoded File Scripts/
│ ├───Find duplicates/
│ │ ├───findanddelete.py
│ │ └───main.py
│ ├───RMF Transcoded File Scripts/
│ │ ├───Find duplicates/
│ │ │ ├───findanddelete.py
│ │ │ └───main.py
│ │ ├───rmf_compare/
│ │ │ ├───did.py
│ │ │ └───main.py
│ │ └───transcode replacements/
│ │ └───MAIN.py
│ └───transcode replacements/
│ └───MAIN.py
└───stashtoolkit_webtop/
├───backend/
│ └───app.py
└───static/
└───index.html
Reminder: Do not return an empty response when a tool call is required.
My setup is complete. I will provide my first command in the next turn.Build a context folder and create files for our chat history, and your directives thus far so we can pick this backup later if we need to.
Current File Path:
```json
/home/david/Documents/vscode/personal_development/config.json
```
PRIOR CHAT HISTORY: I need you to take the scripts in /home/david/Documents/vscode/personal_development/stashtoolkit/Transcoded File Scripts and create a singular tool that can execute multiple actions. The scripts will also need to be recontextualized to be run from a linux machine in stead of windows.
1. This should be a web tool, like a singular HTML page.
2. This should allow the user to hook into a stash instance, and it's correspond database which is a sqlite db.
3. The tool should be able to take transcoded files and replace them with original file location as found using the information in the sqlite db.
4. The tool should be able to identify files that share the same file names, but also provide the user this information along with other important values like duration, file path and file size for determination as to whether they would like to remove other files and keep which files.
5. All actions that are permanent like deleting files and overwriting files must be first approved by the user.
continue
The web page is prompting the user for directories. This information should not be entered by the user. Instead, this should be derived from the existing scripts situated within "/home/david/Documents/vscode/personal_development/stashtoolkit/Transcoded File Scripts"
please be aware that the locations may not necessarily be mounted already by the drive. Instead, they will be connected to over smb sessions each time the user is running them potentially.
For example, isolation is hosted at smb://truenas.local/isolation
media is hosted at smb://servervm/main
sh-5.3$ /bin/python /home/david/Documents/vscode/personal_development/stashtoolkit_webtop/backend/app.py
Traceback (most recent call last):
File "/home/david/Documents/vscode/personal_development/stashtoolkit_webtop/backend/app.py", line 5, in <module>
from smbprotocol import smb_client
ImportError: cannot import name 'smb_client' from 'smbprotocol' (/var/data/python/lib/python3.13/site-packages/smbprotocol/__init__.py)
sh-5.3$ /bin/python /home/david/Documents/vscode/personal_development/stashtoolkit_webtop/backend/app.py
Traceback (most recent call last):
File "/home/david/Documents/vscode/personal_development/stashtoolkit_webtop/backend/app.py", line 5, in <module>
from smbprotocol import smb_client
ImportError: cannot import name 'smb_client' from 'smbprotocol' (/var/data/python/lib/python3.13/site-packages/smbprotocol/__init__.py)
Failed to scan directory: Failed to connect to 'smb::445': [Errno -2] Name or service not known
Failed to scan directory: Failed to authenticate with server: SpnegoError (1): SpnegoError (16): Operation not supported or available, Context: No username or password was specified and the credential cache did not exist or contained no credentials, Context: Unable to negotiate common mechanism
make sure you are asking for credentials for each of the 2 smb credentials. Also, store them permanently once the user enters them in the web page so they do not need to enter them each time. Be sure to store them securely.
Now make the site appear much prettier. It needs to be modern, dynamic and bubbly
only display the prompt for credentials in the page if there are no existing save credentials. provide a button that will dynamically show fields if the user would like to use new credentials.
add a status check section for access the stash db to show whether or not the page was able to access the sql db.
I encountered the error: module 'smbclient' has no attribute 'open'
Additionally, the check should happen when the server comes online and allow the user to refresh on command. It shouldnt wait for the user to click a check button.
After waiting for it to check, it failed with this error.
Failed to connect to 'servervm:445': [Errno -2] Name or service not known
please be aware that both truenas and servervm will need to use the .local hostname.
Additional enhancements:
1. Add a section for checking the health of the connection to truenas.local.
2. Add a "file explorer" section where users can navigate through truenas.local's isolation smb share, and manage files like previewing video files, image files, and allow the user to delete them, rename them, move them.
continue building.
each of the status checks should happen in parallel instead of sequentially.
each of the status checks should happen in parallel instead of sequentially.
Build a context folder and create files for our chat history, and your directives thus far so we can pick this backup later if we need to.
+19
View File
@@ -0,0 +1,19 @@
* Create a singular web tool from the scripts in `/home/david/Documents/vscode/personal_development/stashtoolkit/Transcoded File Scripts`.
* The tool should run on Linux.
* The tool should connect to a Stash instance and its SQLite database.
* The tool should replace transcoded files with original files based on information from the database.
* The tool should identify duplicate files by name and provide details like duration, file path, and file size for user review and action.
* All destructive actions (deleting, overwriting) must be confirmed by the user.
* The web page should not prompt for directories; it should derive them from the existing scripts.
* The tool must handle SMB connections to `smb://truenas.local/isolation` and `smb://servervm/main`.
* The tool must prompt for SMB credentials and store them securely for future use.
* The UI should be modern, dynamic, and "bubbly."
* Credential prompts should only appear if no credentials are saved. A button should be available to enter new credentials.
* A status check for the Stash database connection should be displayed.
* The Stash DB connection check should happen automatically on server startup and be refreshable by the user.
* The tool needs to handle the `ImportError: cannot import name 'smb_client' from 'smbprotocol'`.
* The tool needs to handle the `module 'smbclient' has no attribute 'open'`.
* The tool needs to handle the `Failed to connect to 'servervm:445': [Errno -2] Name or service not known` error by using `.local` hostnames.
* Add a health check for the `truenas.local` connection.
* Add a file explorer to navigate the `truenas.local` isolation SMB share, with features for previewing, deleting, renaming, and moving files.
* All status checks should run in parallel.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
flask[async]
smbprotocol
qbittorrent-api
requests
beautifulsoup4
File diff suppressed because it is too large Load Diff