uploading all of the changes to implement web app
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -0,0 +1 @@
|
||||
{"truenas.local": {"username": "home", "password": "kifer"}, "servervm.local": {"username": "server", "password": "password1"}}
|
||||
@@ -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.
|
||||
@@ -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.
@@ -0,0 +1,5 @@
|
||||
flask[async]
|
||||
smbprotocol
|
||||
qbittorrent-api
|
||||
requests
|
||||
beautifulsoup4
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user