import os import shutil import zipfile import threading import time import humanize from flask import Flask, request, jsonify, render_template_string import gdown from huggingface_hub import HfApi, login, upload_folder, hf_hub_url from huggingface_hub.utils import HfHubHTTPError # --- Configuration & Initialization --- # Ensure Hugging Face cache and other temp data writes to /tmp os.environ["HF_HOME"] = "/tmp/hf_home" DOWNLOAD_DIR = "/tmp/backups" EXTRACT_DIR = "/tmp/extracted_backups" # Environment variables (set these in your Space secrets) FOLDER_URL = os.getenv("FOLDER_URL") REPO_ID = os.getenv("REPO_ID") TOKEN = os.getenv("HF_TOKEN") # --- Global State Management --- app_state = { "backup_status": "idle", # idle, running, success, error "backup_log": ["Awaiting first run."], "last_backup_time": "Never", "next_backup_time": "Scheduler disabled", "schedule_interval_minutes": 0, # 0 means disabled "scheduler_thread": None # This will hold the non-serializable Thread object } # --- Flask App Setup --- app = Flask(__name__) api = HfApi() # --- HTML, CSS, JS Template --- HTML_TEMPLATE = """ Backup & Dataset Controller
Minecraft Backup & Dataset Controller
Backup Controls
Idle
  • Last Backup: Never
  • Next Scheduled: N/A
Dataset Management
View on Hub

Files in {{ repo_id }}

File Path Size Actions
""" # --- Core Backup Logic --- def run_backup_job(): global app_state app_state["backup_status"] = "running" app_state["backup_log"] = ["Starting backup process..."] def log(message): print(message) app_state["backup_log"].append(message) try: log("Resetting temporary directories...") shutil.rmtree(DOWNLOAD_DIR, ignore_errors=True) shutil.rmtree(EXTRACT_DIR, ignore_errors=True) os.makedirs(DOWNLOAD_DIR, exist_ok=True) os.makedirs(EXTRACT_DIR, exist_ok=True) log("Downloading from Google Drive folder...") gdown.download_folder(url=FOLDER_URL, output=DOWNLOAD_DIR, use_cookies=False, quiet=True) log("Download finished.") log("Extracting zip archives...") extracted_count = 0 for root, _, files in os.walk(DOWNLOAD_DIR): for f in files: if f.endswith(".zip"): zp = os.path.join(root, f) with zipfile.ZipFile(zp) as z: z.extractall(EXTRACT_DIR) log(f"Extracted: {f}") extracted_count += 1 if extracted_count == 0: log("Warning: No .zip files found to extract.") bad_path = os.path.join(EXTRACT_DIR, "world_nither") good_path = os.path.join(EXTRACT_DIR, "world_nether") if os.path.exists(bad_path) and not os.path.exists(good_path): os.rename(bad_path, good_path) log("Fixed folder name typo: 'world_nither' -> 'world_nether'") log("Logging into Hugging Face Hub...") login(token=TOKEN) log(f"Ensuring dataset repository '{REPO_ID}' exists...") api.create_repo(repo_id=REPO_ID, repo_type="dataset", private=False, exist_ok=True) log("Repository is ready.") subfolders_to_upload = { "world": os.path.join(EXTRACT_DIR, "world"), "world_nether": os.path.join(EXTRACT_DIR, "world_nether"), "world_the_end": os.path.join(EXTRACT_DIR, "world_the_end"), "plugins": os.path.join(EXTRACT_DIR, "plugins") } for name, path in subfolders_to_upload.items(): if os.path.exists(path): log(f"Uploading '{name}'...") upload_folder( repo_id=REPO_ID, folder_path=path, repo_type="dataset", path_in_repo=name, commit_message=f"Backup update for {name}" ) log(f"'{name}' uploaded successfully.") else: log(f"Skipping '{name}' - directory not found.") app_state["last_backup_time"] = time.strftime("%Y-%m-%d %H:%M:%S %Z") log(f"Backup completed successfully at {app_state['last_backup_time']}.") app_state["backup_status"] = "success" except Exception as e: log(f"AN ERROR OCCURRED: {str(e)}") app_state["backup_status"] = "error" # --- Scheduler Thread --- def scheduler_loop(): global app_state while True: interval = app_state["schedule_interval_minutes"] if interval > 0: if app_state["backup_status"] != "running": run_backup_job() next_run_timestamp = time.time() + interval * 60 app_state["next_backup_time"] = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime(next_run_timestamp)) time.sleep(interval * 60) else: app_state["next_backup_time"] = "Scheduler disabled" time.sleep(5) # --- Flask Routes (API Endpoints) --- @app.route("/") def index(): return render_template_string(HTML_TEMPLATE, repo_id=REPO_ID) # =================================================================== # THIS IS THE CORRECTED FUNCTION # =================================================================== @app.route("/api/status", methods=["GET"]) def get_status(): """Provides a JSON-safe snapshot of the application state.""" # Create a copy of the state dictionary that EXCLUDES the non-serializable thread object. state_for_json = { key: value for key, value in app_state.items() if key != "scheduler_thread" } return jsonify(state_for_json) # =================================================================== @app.route("/api/run-backup", methods=["POST"]) def start_backup(): if app_state["backup_status"] == "running": return jsonify({"status": "error", "message": "A backup is already in progress."}), 409 threading.Thread(target=run_backup_job, daemon=True).start() return jsonify({"status": "ok", "message": "Backup process started."}) @app.route("/api/set-schedule", methods=["POST"]) def set_schedule(): try: interval = int(request.json.get("interval", 0)) if interval < 0: raise ValueError() app_state["schedule_interval_minutes"] = interval if interval > 0: next_run_timestamp = time.time() + interval * 60 app_state["next_backup_time"] = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime(next_run_timestamp)) else: app_state["next_backup_time"] = "Scheduler disabled" return jsonify({"status": "ok", "message": f"Schedule set to {interval} minutes."}) except (ValueError, TypeError): return jsonify({"status": "error", "message": "Invalid interval value."}), 400 @app.route("/api/list-files", methods=["GET"]) def list_repo_files(): try: repo_files = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset") files_details = [] for filename in repo_files: try: info = api.get_repo_file_info(repo_id=REPO_ID, path_in_repo=filename, repo_type="dataset") size = humanize.naturalsize(info.size) if info.size else "0 B" except HfHubHTTPError: size = "N/A" files_details.append({ "name": filename, "size": size, "url": hf_hub_url(repo_id=REPO_ID, filename=filename, repo_type="dataset") }) return jsonify({"status": "ok", "files": files_details}) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @app.route("/api/delete-file", methods=["POST"]) def delete_repo_file(): filename = request.json.get("filename") if not filename: return jsonify({"status": "error", "message": "Filename not provided."}), 400 try: api.delete_file( repo_id=REPO_ID, path_in_repo=filename, repo_type="dataset", commit_message=f"Deleted file: {filename}" ) return jsonify({"status": "ok", "message": f"Successfully deleted '{filename}'."}) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 # --- Main Execution --- if __name__ == "__main__": app_state["scheduler_thread"] = threading.Thread(target=scheduler_loop, daemon=True) app_state["scheduler_thread"].start() app.run(host="0.0.0.0", port=7860)