"""Remote Storage Server - REST API for file management on mobile devices. This server runs on a mobile device (Android with Termux, iOS with iSH, etc.) and exposes the local library database as a REST API. Your PC connects to this server and uses it as a remote storage backend through the RemoteStorageBackend. ## INSTALLATION ### On Android (Termux): 1. Install Termux from Play Store: https://play.google.com/store/apps/details?id=com.termux 2. In Termux: $ apt update && apt install python $ pip install flask flask-cors 3. Copy this file to your device 4. Run it (with optional API key): $ python remote_storage_server.py --storage-path /path/to/storage --port 5000 $ python remote_storage_server.py --storage-path /path/to/storage --api-key mysecretkey 5. Server prints connection info automatically (IP, port, API key) ### On PC: 1. Install requests: pip install requests 2. Add to config.json: { "remote_storages": [ { "name": "phone", "url": "http://192.168.1.100:5000", "api_key": "mysecretkey", "timeout": 30 } ] } Note: API key is optional. Works on WiFi or cellular data. ## USAGE After setup, all cmdlets work with the phone: $ search-file zohar -store phone $ @1-3 | add-relationship -king @4 -store phone $ @1 | get-relationship -store phone The server exposes REST endpoints that RemoteStorageBackend uses internally. """ from __future__ import annotations import os import sys import json import argparse import logging from pathlib import Path from typing import Optional, Dict, Any from datetime import datetime from functools import wraps # Add parent directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) from helper.logger import log # ============================================================================ # CONFIGURATION # ============================================================================ logging.basicConfig( level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s' ) logger = logging.getLogger(__name__) STORAGE_PATH: Optional[Path] = None API_KEY: Optional[str] = None # API key for authentication (None = no auth required) # Try importing Flask - will be used in main() only try: from flask import Flask, request, jsonify from flask_cors import CORS HAS_FLASK = True except ImportError: HAS_FLASK = False # ============================================================================ # UTILITY FUNCTIONS # ============================================================================ def get_local_ip() -> Optional[str]: """Get the local IP address that would be used for external connections.""" import socket try: # Create a socket to determine which interface would be used s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) # Google DNS ip = s.getsockname()[0] s.close() return ip except Exception: return None # ============================================================================ # FLASK APP FACTORY # ============================================================================ def create_app(): """Create and configure Flask app with all routes.""" if not HAS_FLASK: raise ImportError("Flask not installed. Install with: pip install flask flask-cors") from flask import Flask, request, jsonify from flask_cors import CORS app = Flask(__name__) CORS(app) # ======================================================================== # HELPER DECORATORS # ======================================================================== def require_auth(): """Decorator to check API key authentication if configured.""" def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): if API_KEY: # Get API key from header or query parameter provided_key = request.headers.get('X-API-Key') or request.args.get('api_key') if not provided_key or provided_key != API_KEY: return jsonify({"error": "Unauthorized. Invalid or missing API key."}), 401 return f(*args, **kwargs) return decorated_function return decorator def require_storage(): """Decorator to ensure storage path is configured.""" def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): if not STORAGE_PATH: return jsonify({"error": "Storage path not configured"}), 500 return f(*args, **kwargs) return decorated_function return decorator # ======================================================================== # HEALTH CHECK # ======================================================================== @app.route('/health', methods=['GET']) @require_auth() def health(): """Check server health and storage availability.""" status = { "status": "ok", "storage_configured": STORAGE_PATH is not None, "timestamp": datetime.now().isoformat() } if STORAGE_PATH: status["storage_path"] = str(STORAGE_PATH) status["storage_exists"] = STORAGE_PATH.exists() try: from helper.local_library import LocalLibraryDB with LocalLibraryDB(STORAGE_PATH) as db: status["database_accessible"] = True except Exception as e: status["database_accessible"] = False status["database_error"] = str(e) return jsonify(status), 200 # ======================================================================== # FILE OPERATIONS # ======================================================================== @app.route('/files/search', methods=['GET']) @require_auth() @require_storage() def search_files(): """Search for files by name or tag.""" from helper.local_library import LocalLibrarySearchOptimizer query = request.args.get('q', '') limit = request.args.get('limit', 100, type=int) if not query: return jsonify({"error": "Search query required"}), 400 try: with LocalLibrarySearchOptimizer(STORAGE_PATH) as db: results = db.search_by_name(query, limit) tag_results = db.search_by_tag(query, limit) all_results = {r['hash']: r for r in (results + tag_results)} return jsonify({ "query": query, "count": len(all_results), "files": list(all_results.values()) }), 200 except Exception as e: logger.error(f"Search error: {e}", exc_info=True) return jsonify({"error": f"Search failed: {str(e)}"}), 500 @app.route('/files/', methods=['GET']) @require_auth() @require_storage() def get_file_metadata(file_hash: str): """Get metadata for a specific file by hash.""" from helper.local_library import LocalLibraryDB try: with LocalLibraryDB(STORAGE_PATH) as db: file_path = db.search_by_hash(file_hash) if not file_path or not file_path.exists(): return jsonify({"error": "File not found"}), 404 metadata = db.get_metadata(file_path) tags = db.get_tags(file_path) return jsonify({ "hash": file_hash, "path": str(file_path), "size": file_path.stat().st_size, "metadata": metadata, "tags": tags }), 200 except Exception as e: logger.error(f"Get metadata error: {e}", exc_info=True) return jsonify({"error": f"Failed to get metadata: {str(e)}"}), 500 @app.route('/files/index', methods=['POST']) @require_auth() @require_storage() def index_file(): """Index a new file in the storage.""" from helper.local_library import LocalLibraryDB from helper.utils import sha256_file data = request.get_json() or {} file_path_str = data.get('path') tags = data.get('tags', []) urls = data.get('urls', []) if not file_path_str: return jsonify({"error": "File path required"}), 400 try: file_path = Path(file_path_str) if not file_path.exists(): return jsonify({"error": "File does not exist"}), 404 with LocalLibraryDB(STORAGE_PATH) as db: db.get_or_create_file_entry(file_path) if tags: db.add_tags(file_path, tags) if urls: db.add_known_urls(file_path, urls) file_hash = sha256_file(file_path) return jsonify({ "hash": file_hash, "path": str(file_path), "tags_added": len(tags), "urls_added": len(urls) }), 201 except Exception as e: logger.error(f"Index error: {e}", exc_info=True) return jsonify({"error": f"Indexing failed: {str(e)}"}), 500 # ======================================================================== # TAG OPERATIONS # ======================================================================== @app.route('/tags/', methods=['GET']) @require_auth() @require_storage() def get_tags(file_hash: str): """Get tags for a file.""" from helper.local_library import LocalLibraryDB try: with LocalLibraryDB(STORAGE_PATH) as db: file_path = db.search_by_hash(file_hash) if not file_path: return jsonify({"error": "File not found"}), 404 tags = db.get_tags(file_path) return jsonify({"hash": file_hash, "tags": tags}), 200 except Exception as e: logger.error(f"Get tags error: {e}", exc_info=True) return jsonify({"error": f"Failed: {str(e)}"}), 500 @app.route('/tags/', methods=['POST']) @require_auth() @require_storage() def add_tags(file_hash: str): """Add tags to a file.""" from helper.local_library import LocalLibraryDB data = request.get_json() or {} tags = data.get('tags', []) mode = data.get('mode', 'add') if not tags: return jsonify({"error": "Tags required"}), 400 try: with LocalLibraryDB(STORAGE_PATH) as db: file_path = db.search_by_hash(file_hash) if not file_path: return jsonify({"error": "File not found"}), 404 if mode == 'replace': db.remove_tags(file_path, db.get_tags(file_path)) db.add_tags(file_path, tags) return jsonify({"hash": file_hash, "tags_added": len(tags), "mode": mode}), 200 except Exception as e: logger.error(f"Add tags error: {e}", exc_info=True) return jsonify({"error": f"Failed: {str(e)}"}), 500 @app.route('/tags/', methods=['DELETE']) @require_auth() @require_storage() def remove_tags(file_hash: str): """Remove tags from a file.""" from helper.local_library import LocalLibraryDB tags_str = request.args.get('tags', '') try: with LocalLibraryDB(STORAGE_PATH) as db: file_path = db.search_by_hash(file_hash) if not file_path: return jsonify({"error": "File not found"}), 404 if tags_str: tags_to_remove = [t.strip() for t in tags_str.split(',')] else: tags_to_remove = db.get_tags(file_path) db.remove_tags(file_path, tags_to_remove) return jsonify({"hash": file_hash, "tags_removed": len(tags_to_remove)}), 200 except Exception as e: logger.error(f"Remove tags error: {e}", exc_info=True) return jsonify({"error": f"Failed: {str(e)}"}), 500 # ======================================================================== # RELATIONSHIP OPERATIONS # ======================================================================== @app.route('/relationships/', methods=['GET']) @require_auth() @require_storage() def get_relationships(file_hash: str): """Get relationships for a file.""" from helper.local_library import LocalLibraryDB try: with LocalLibraryDB(STORAGE_PATH) as db: file_path = db.search_by_hash(file_hash) if not file_path: return jsonify({"error": "File not found"}), 404 metadata = db.get_metadata(file_path) relationships = metadata.get('relationships', {}) if metadata else {} return jsonify({"hash": file_hash, "relationships": relationships}), 200 except Exception as e: logger.error(f"Get relationships error: {e}", exc_info=True) return jsonify({"error": f"Failed: {str(e)}"}), 500 @app.route('/relationships', methods=['POST']) @require_auth() @require_storage() def set_relationship(): """Set a relationship between two files.""" from helper.local_library import LocalLibraryDB data = request.get_json() or {} from_hash = data.get('from_hash') to_hash = data.get('to_hash') rel_type = data.get('type', 'alt') if not from_hash or not to_hash: return jsonify({"error": "from_hash and to_hash required"}), 400 try: with LocalLibraryDB(STORAGE_PATH) as db: from_path = db.search_by_hash(from_hash) to_path = db.search_by_hash(to_hash) if not from_path or not to_path: return jsonify({"error": "File not found"}), 404 db.set_relationship(from_path, to_path, rel_type) return jsonify({"from_hash": from_hash, "to_hash": to_hash, "type": rel_type}), 200 except Exception as e: logger.error(f"Set relationship error: {e}", exc_info=True) return jsonify({"error": f"Failed: {str(e)}"}), 500 # ======================================================================== # URL OPERATIONS # ======================================================================== @app.route('/urls/', methods=['GET']) @require_auth() @require_storage() def get_urls(file_hash: str): """Get known URLs for a file.""" from helper.local_library import LocalLibraryDB try: with LocalLibraryDB(STORAGE_PATH) as db: file_path = db.search_by_hash(file_hash) if not file_path: return jsonify({"error": "File not found"}), 404 metadata = db.get_metadata(file_path) urls = metadata.get('known_urls', []) if metadata else [] return jsonify({"hash": file_hash, "urls": urls}), 200 except Exception as e: logger.error(f"Get URLs error: {e}", exc_info=True) return jsonify({"error": f"Failed: {str(e)}"}), 500 @app.route('/urls/', methods=['POST']) @require_auth() @require_storage() def add_urls(file_hash: str): """Add URLs to a file.""" from helper.local_library import LocalLibraryDB data = request.get_json() or {} urls = data.get('urls', []) if not urls: return jsonify({"error": "URLs required"}), 400 try: with LocalLibraryDB(STORAGE_PATH) as db: file_path = db.search_by_hash(file_hash) if not file_path: return jsonify({"error": "File not found"}), 404 db.add_known_urls(file_path, urls) return jsonify({"hash": file_hash, "urls_added": len(urls)}), 200 except Exception as e: logger.error(f"Add URLs error: {e}", exc_info=True) return jsonify({"error": f"Failed: {str(e)}"}), 500 return app # ============================================================================ # MAIN # ============================================================================ def main(): if not HAS_FLASK: print("ERROR: Flask and flask-cors required") print("Install with: pip install flask flask-cors") sys.exit(1) parser = argparse.ArgumentParser( description='Remote Storage Server for Medios-Macina', epilog='Example: python remote_storage_server.py --storage-path /storage/media --port 5000 --api-key mysecretkey' ) parser.add_argument('--storage-path', type=str, required=True, help='Path to storage directory') parser.add_argument('--host', type=str, default='0.0.0.0', help='Server host (default: 0.0.0.0)') parser.add_argument('--port', type=int, default=5000, help='Server port (default: 5000)') parser.add_argument('--api-key', type=str, default=None, help='API key for authentication (optional)') parser.add_argument('--debug', action='store_true', help='Enable debug mode') args = parser.parse_args() global STORAGE_PATH, API_KEY STORAGE_PATH = Path(args.storage_path).resolve() API_KEY = args.api_key if not STORAGE_PATH.exists(): print(f"ERROR: Storage path does not exist: {STORAGE_PATH}") sys.exit(1) # Get local IP address local_ip = get_local_ip() if not local_ip: local_ip = "127.0.0.1" print(f"\n{'='*70}") print(f"Remote Storage Server - Medios-Macina") print(f"{'='*70}") print(f"Storage Path: {STORAGE_PATH}") print(f"Local IP: {local_ip}") print(f"Server URL: http://{local_ip}:{args.port}") print(f"Health URL: http://{local_ip}:{args.port}/health") print(f"API Key: {'Enabled - ' + ('***' + args.api_key[-4:]) if args.api_key else 'Disabled (no auth)'}") print(f"Debug Mode: {args.debug}") print(f"\nšŸ“‹ Config for config.json:") config_entry = { "name": "phone", "url": f"http://{local_ip}:{args.port}", "timeout": 30 } if args.api_key: config_entry["api_key"] = args.api_key print(json.dumps(config_entry, indent=2)) print(f"\n{'='*70}\n") try: from helper.local_library import LocalLibraryDB with LocalLibraryDB(STORAGE_PATH) as db: logger.info("Database initialized successfully") except Exception as e: logger.error(f"Failed to initialize database: {e}") sys.exit(1) app = create_app() app.run(host=args.host, port=args.port, debug=args.debug, use_reloader=False) if __name__ == '__main__': main()