580 lines
19 KiB
Python
580 lines
19 KiB
Python
"""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.conf:
|
|
[store=remote]
|
|
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 cmdlet 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 SYS.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 API.folder import API_folder_store
|
|
|
|
with API_folder_store(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 API.folder 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/<file_hash>", methods=["GET"])
|
|
@require_auth()
|
|
@require_storage()
|
|
def get_file_metadata(file_hash: str):
|
|
"""Get metadata for a specific file by hash."""
|
|
from API.folder import API_folder_store
|
|
|
|
try:
|
|
with API_folder_store(STORAGE_PATH) as db:
|
|
file_path = db.search_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,
|
|
"tag": 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 API.folder import API_folder_store
|
|
from SYS.utils import sha256_file
|
|
|
|
data = request.get_json() or {}
|
|
file_path_str = data.get("path")
|
|
tags = data.get("tag", [])
|
|
url = data.get("url", [])
|
|
|
|
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 API_folder_store(STORAGE_PATH) as db:
|
|
db.get_or_create_file_entry(file_path)
|
|
|
|
if tags:
|
|
db.add_tags(file_path, tags)
|
|
|
|
if url:
|
|
db.add_url(file_path, url)
|
|
|
|
file_hash = sha256_file(file_path)
|
|
|
|
return (
|
|
jsonify(
|
|
{
|
|
"hash": file_hash,
|
|
"path": str(file_path),
|
|
"tags_added": len(tags),
|
|
"url_added": len(url),
|
|
}
|
|
),
|
|
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/<file_hash>", methods=["GET"])
|
|
@require_auth()
|
|
@require_storage()
|
|
def get_tags(file_hash: str):
|
|
"""Get tags for a file."""
|
|
from API.folder import API_folder_store
|
|
|
|
try:
|
|
with API_folder_store(STORAGE_PATH) as db:
|
|
file_path = db.search_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, "tag": 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/<file_hash>", methods=["POST"])
|
|
@require_auth()
|
|
@require_storage()
|
|
def add_tags(file_hash: str):
|
|
"""Add tags to a file."""
|
|
from API.folder import API_folder_store
|
|
|
|
data = request.get_json() or {}
|
|
tags = data.get("tag", [])
|
|
mode = data.get("mode", "add")
|
|
|
|
if not tags:
|
|
return jsonify({"error": "Tag required"}), 400
|
|
|
|
try:
|
|
with API_folder_store(STORAGE_PATH) as db:
|
|
file_path = db.search_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, "tag_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/<file_hash>", methods=["DELETE"])
|
|
@require_auth()
|
|
@require_storage()
|
|
def remove_tags(file_hash: str):
|
|
"""Remove tags from a file."""
|
|
from API.folder import API_folder_store
|
|
|
|
tags_str = request.args.get("tag", "")
|
|
|
|
try:
|
|
with API_folder_store(STORAGE_PATH) as db:
|
|
file_path = db.search_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/<file_hash>", methods=["GET"])
|
|
@require_auth()
|
|
@require_storage()
|
|
def get_relationships(file_hash: str):
|
|
"""Get relationships for a file."""
|
|
from API.folder import API_folder_store
|
|
|
|
try:
|
|
with API_folder_store(STORAGE_PATH) as db:
|
|
file_path = db.search_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 API.folder import API_folder_store
|
|
|
|
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 API_folder_store(STORAGE_PATH) as db:
|
|
from_path = db.search_hash(from_hash)
|
|
to_path = db.search_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("/url/<file_hash>", methods=["GET"])
|
|
@require_auth()
|
|
@require_storage()
|
|
def get_url(file_hash: str):
|
|
"""Get known url for a file."""
|
|
from API.folder import API_folder_store
|
|
|
|
try:
|
|
with API_folder_store(STORAGE_PATH) as db:
|
|
file_path = db.search_hash(file_hash)
|
|
if not file_path:
|
|
return jsonify({"error": "File not found"}), 404
|
|
|
|
metadata = db.get_metadata(file_path)
|
|
url = metadata.get("url", []) if metadata else []
|
|
return jsonify({"hash": file_hash, "url": url}), 200
|
|
except Exception as e:
|
|
logger.error(f"Get url error: {e}", exc_info=True)
|
|
return jsonify({"error": f"Failed: {str(e)}"}), 500
|
|
|
|
@app.route("/url/<file_hash>", methods=["POST"])
|
|
@require_auth()
|
|
@require_storage()
|
|
def add_url(file_hash: str):
|
|
"""Add url to a file."""
|
|
from API.folder import API_folder_store
|
|
|
|
data = request.get_json() or {}
|
|
url = data.get("url", [])
|
|
|
|
if not url:
|
|
return jsonify({"error": "url required"}), 400
|
|
|
|
try:
|
|
with API_folder_store(STORAGE_PATH) as db:
|
|
file_path = db.search_hash(file_hash)
|
|
if not file_path:
|
|
return jsonify({"error": "File not found"}), 404
|
|
|
|
db.add_url(file_path, url)
|
|
return jsonify({"hash": file_hash, "url_added": len(url)}), 200
|
|
except Exception as e:
|
|
logger.error(f"Add url 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("\n📋 Config for config.conf:")
|
|
print("[store=remote]")
|
|
print('name="phone"')
|
|
print(f'url="http://{local_ip}:{args.port}"')
|
|
if args.api_key:
|
|
print(f'api_key="{args.api_key}"')
|
|
print("timeout=30")
|
|
print(f"\n{'='*70}\n")
|
|
|
|
try:
|
|
from API.folder import API_folder_store
|
|
|
|
with API_folder_store(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()
|