This commit is contained in:
2026-01-14 15:56:04 -08:00
parent 0f726b11dc
commit d474916874
2 changed files with 159 additions and 126 deletions

View File

@@ -864,22 +864,23 @@ class API_folder_store:
def get_metadata(self, file_hash: str) -> Optional[Dict[str, Any]]: def get_metadata(self, file_hash: str) -> Optional[Dict[str, Any]]:
"""Get metadata for a file by hash.""" """Get metadata for a file by hash."""
try: try:
cursor = self.connection.cursor() with self._db_lock:
cursor = self.connection.cursor()
cursor.execute( cursor.execute(
""" """
SELECT m.* FROM metadata m SELECT m.* FROM metadata m
WHERE m.hash = ? WHERE m.hash = ?
""", """,
(file_hash, (file_hash,
), ),
) )
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
return None return None
metadata = dict(row) metadata = dict(row)
# Parse JSON fields # Parse JSON fields
for field in ["url", "relationships"]: for field in ["url", "relationships"]:
@@ -1236,19 +1237,20 @@ class API_folder_store:
def get_tags(self, file_hash: str) -> List[str]: def get_tags(self, file_hash: str) -> List[str]:
"""Get all tags for a file by hash.""" """Get all tags for a file by hash."""
try: try:
cursor = self.connection.cursor() with self._db_lock:
cursor = self.connection.cursor()
cursor.execute( cursor.execute(
""" """
SELECT t.tag FROM tag t SELECT t.tag FROM tag t
WHERE t.hash = ? WHERE t.hash = ?
ORDER BY t.tag ORDER BY t.tag
""", """,
(file_hash, (file_hash,
), ),
) )
return [row[0] for row in cursor.fetchall()] return [row[0] for row in cursor.fetchall()]
except Exception as e: except Exception as e:
logger.error(f"Error getting tags for hash {file_hash}: {e}", exc_info=True) logger.error(f"Error getting tags for hash {file_hash}: {e}", exc_info=True)
return [] return []
@@ -1833,18 +1835,19 @@ class API_folder_store:
def search_hash(self, file_hash: str) -> Optional[Path]: def search_hash(self, file_hash: str) -> Optional[Path]:
"""Search for a file by hash.""" """Search for a file by hash."""
try: try:
cursor = self.connection.cursor() with self._db_lock:
cursor = self.connection.cursor()
cursor.execute( cursor.execute(
""" """
SELECT file_path FROM file WHERE hash = ? SELECT file_path FROM file WHERE hash = ?
""", """,
(file_hash, (file_hash,
), ),
) )
row = cursor.fetchone() row = cursor.fetchone()
return self._from_db_file_path(row[0]) if row else None return self._from_db_file_path(row[0]) if row else None
except Exception as e: except Exception as e:
logger.error(f"Error searching by hash '{file_hash}': {e}", exc_info=True) logger.error(f"Error searching by hash '{file_hash}': {e}", exc_info=True)
return None return None
@@ -3525,13 +3528,15 @@ class LocalLibrarySearchOptimizer:
"""Get tags from database cache.""" """Get tags from database cache."""
if not self.db: if not self.db:
return [] return []
return self.db.get_tags(file_path) file_hash = self.db.get_file_hash(file_path)
return self.db.get_tags(file_hash) if file_hash else []
def get_cached_metadata(self, file_path: Path) -> Optional[Dict[str, Any]]: def get_cached_metadata(self, file_path: Path) -> Optional[Dict[str, Any]]:
"""Get metadata from database cache.""" """Get metadata from database cache."""
if not self.db: if not self.db:
return None return None
return self.db.get_metadata(file_path) file_hash = self.db.get_file_hash(file_path)
return self.db.get_metadata(file_hash) if file_hash else None
def prefetch_metadata(self, file_paths: List[Path]) -> None: def prefetch_metadata(self, file_paths: List[Path]) -> None:
"""Pre-cache metadata for multiple files.""" """Pre-cache metadata for multiple files."""
@@ -3554,11 +3559,15 @@ class LocalLibrarySearchOptimizer:
return return
try: try:
tags = self.db.get_tags(file_path) file_hash = self.db.get_file_hash(file_path)
if not file_hash:
return
tags = self.db.get_tags(file_hash)
if tags: if tags:
search_result.tag_summary = ", ".join(tags) search_result.tag_summary = ", ".join(tags)
metadata = self.db.get_metadata(file_path) metadata = self.db.get_metadata(file_hash)
if metadata: if metadata:
if "hash" in metadata: if "hash" in metadata:
search_result.hash_hex = metadata["hash"] search_result.hash_hex = metadata["hash"]
@@ -3575,27 +3584,28 @@ class LocalLibrarySearchOptimizer:
return [] return []
try: try:
cursor = self.db.connection.cursor() with self.db._db_lock:
cursor.execute( cursor = self.db.connection.cursor()
""" cursor.execute(
SELECT f.hash, f.file_path, m.duration, m.size, m.type as media_kind, m.url """
FROM file f SELECT f.hash, f.file_path, m.duration, m.size, m.type as media_kind, m.url
JOIN tag t ON f.hash = t.hash FROM file f
LEFT JOIN metadata m ON f.hash = m.hash JOIN tag t ON f.hash = t.hash
WHERE t.tag LIKE ? LEFT JOIN metadata m ON f.hash = m.hash
LIMIT ? WHERE t.tag LIKE ?
""", LIMIT ?
(f"%{tag}%", """,
limit), (f"%{tag}%",
) limit),
)
results = [] results = []
for row in cursor.fetchall(): for row in cursor.fetchall():
res = dict(row) res = dict(row)
# Resolve path to absolute string for remote consumption # Resolve path to absolute string for remote consumption
res["file_path"] = str(self.db._from_db_file_path(res["file_path"])) res["file_path"] = str(self.db._from_db_file_path(res["file_path"]))
results.append(res) results.append(res)
return results return results
except Exception as e: except Exception as e:
logger.error(f"Tag search failed: {e}") logger.error(f"Tag search failed: {e}")
return [] return []
@@ -3606,26 +3616,27 @@ class LocalLibrarySearchOptimizer:
return [] return []
try: try:
cursor = self.db.connection.cursor() with self.db._db_lock:
cursor.execute( cursor = self.db.connection.cursor()
""" cursor.execute(
SELECT f.hash, f.file_path, m.duration, m.size, m.type as media_kind, m.url """
FROM file f SELECT f.hash, f.file_path, m.duration, m.size, m.type as media_kind, m.url
LEFT JOIN metadata m ON f.hash = m.hash FROM file f
WHERE f.file_path LIKE ? LEFT JOIN metadata m ON f.hash = m.hash
LIMIT ? WHERE f.file_path LIKE ?
""", LIMIT ?
(f"%{query}%", """,
limit), (f"%{query}%",
) limit),
)
results = [] results = []
for row in cursor.fetchall(): for row in cursor.fetchall():
res = dict(row) res = dict(row)
# Resolve path to absolute string for remote consumption # Resolve path to absolute string for remote consumption
res["file_path"] = str(self.db._from_db_file_path(res["file_path"])) res["file_path"] = str(self.db._from_db_file_path(res["file_path"]))
results.append(res) results.append(res)
return results return results
except Exception as e: except Exception as e:
logger.error(f"Name search failed: {e}") logger.error(f"Name search failed: {e}")
return [] return []

View File

@@ -69,6 +69,17 @@ logger = logging.getLogger(__name__)
STORAGE_PATH: Optional[Path] = None STORAGE_PATH: Optional[Path] = None
API_KEY: Optional[str] = None # API key for authentication (None = no auth required) API_KEY: Optional[str] = None # API key for authentication (None = no auth required)
# Cache for database connection to prevent "database is locked" on high frequency requests
_DB_CACHE: Dict[str, Any] = {}
def get_db(path: Path):
from API.folder import LocalLibrarySearchOptimizer
p_str = str(path)
if p_str not in _DB_CACHE:
_DB_CACHE[p_str] = LocalLibrarySearchOptimizer(path)
_DB_CACHE[p_str].__enter__()
return _DB_CACHE[p_str]
# Try importing Flask - will be used in main() only # Try importing Flask - will be used in main() only
try: try:
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
@@ -199,25 +210,34 @@ def create_app():
# ======================================================================== # ========================================================================
@app.route("/health", methods=["GET"]) @app.route("/health", methods=["GET"])
@require_auth()
def health(): def health():
"""Check server health and storage availability.""" """Check server health and storage availability."""
# Check auth manually to allow discovery even if locked
authed = True
if API_KEY:
provided_key = request.headers.get("X-API-Key") or request.args.get("api_key")
if not provided_key or provided_key != API_KEY:
authed = False
status = { status = {
"status": "ok", "status": "ok",
"service": "remote_storage", "service": "remote_storage",
"name": os.environ.get("MM_SERVER_NAME", "Remote Storage"), "name": os.environ.get("MM_SERVER_NAME", "Remote Storage"),
"storage_configured": STORAGE_PATH is not None, "storage_configured": STORAGE_PATH is not None,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"locked": not authed and API_KEY is not None
} }
# If not authed but API_KEY is required, return minimal info for discovery
if not authed and API_KEY:
return jsonify(status), 200
if STORAGE_PATH: if STORAGE_PATH:
status["storage_path"] = str(STORAGE_PATH) status["storage_path"] = str(STORAGE_PATH)
status["storage_exists"] = STORAGE_PATH.exists() status["storage_exists"] = STORAGE_PATH.exists()
try: try:
from API.folder import API_folder_store search_db = get_db(STORAGE_PATH)
status["database_accessible"] = True
with API_folder_store(STORAGE_PATH) as db:
status["database_accessible"] = True
except Exception as e: except Exception as e:
status["database_accessible"] = False status["database_accessible"] = False
status["database_error"] = str(e) status["database_error"] = str(e)
@@ -233,8 +253,6 @@ def create_app():
@require_storage() @require_storage()
def search_files(): def search_files():
"""Search for files by name or tag.""" """Search for files by name or tag."""
from API.folder import LocalLibrarySearchOptimizer, API_folder_store
query = request.args.get("q", "") query = request.args.get("q", "")
limit = request.args.get("limit", 100, type=int) limit = request.args.get("limit", 100, type=int)
@@ -242,30 +260,32 @@ def create_app():
db_query = query if query and query != "*" else "" db_query = query if query and query != "*" else ""
try: try:
with LocalLibrarySearchOptimizer(STORAGE_PATH) as search_db: search_db = get_db(STORAGE_PATH)
results = search_db.search_by_name(db_query, limit) results = search_db.search_by_name(db_query, limit)
tag_results = search_db.search_by_tag(db_query, limit) tag_results = search_db.search_by_tag(db_query, limit)
all_results_dict = { all_results_dict = {
r["hash"]: r r["hash"]: r
for r in (results + tag_results) for r in (results + tag_results)
} }
# Fetch tags for each result to support title extraction on client # Fetch tags for each result to support title extraction on client
with API_folder_store(STORAGE_PATH) as db: if search_db.db:
for res in all_results_dict.values(): for res in all_results_dict.values():
if res.get("file_path"): file_hash = res.get("hash")
res["tag"] = db.get_tags(Path(res["file_path"])) if file_hash:
tags = search_db.db.get_tags(file_hash)
res["tag"] = tags
return ( return (
jsonify( jsonify(
{ {
"query": query, "query": query,
"count": len(all_results_dict), "count": len(all_results_dict),
"files": list(all_results_dict.values()), "files": list(all_results_dict.values()),
} }
), ),
200, 200,
) )
except Exception as e: except Exception as e:
logger.error(f"Search error: {e}", exc_info=True) logger.error(f"Search error: {e}", exc_info=True)
return jsonify({"error": f"Search failed: {str(e)}"}), 500 return jsonify({"error": f"Search failed: {str(e)}"}), 500
@@ -275,30 +295,32 @@ def create_app():
@require_storage() @require_storage()
def get_file_metadata(file_hash: str): def get_file_metadata(file_hash: str):
"""Get metadata for a specific file by hash.""" """Get metadata for a specific file by hash."""
from API.folder import API_folder_store
try: try:
with API_folder_store(STORAGE_PATH) as db: search_db = get_db(STORAGE_PATH)
file_path = db.search_hash(file_hash) db = search_db.db
if not db:
return jsonify({"error": "Database unavailable"}), 500
if not file_path or not file_path.exists(): file_path = db.search_hash(file_hash)
return jsonify({"error": "File not found"}), 404
metadata = db.get_metadata(file_path) if not file_path or not file_path.exists():
tags = db.get_tags(file_path) return jsonify({"error": "File not found"}), 404
return ( metadata = db.get_metadata(file_hash)
jsonify( tags = db.get_tags(file_hash) # Use hash string
{
"hash": file_hash, return (
"path": str(file_path), jsonify(
"size": file_path.stat().st_size, {
"metadata": metadata, "hash": file_hash,
"tag": tags, "path": str(file_path),
} "size": file_path.stat().st_size,
), "metadata": metadata,
200, "tag": tags,
) }
),
200,
)
except Exception as e: except Exception as e:
logger.error(f"Get metadata error: {e}", exc_info=True) logger.error(f"Get metadata error: {e}", exc_info=True)
return jsonify({"error": f"Failed to get metadata: {str(e)}"}), 500 return jsonify({"error": f"Failed to get metadata: {str(e)}"}), 500