Signed-off-by: linyueqian <linyueqian@outlook.com> Co-authored-by: linyueqian <linyueqian@outlook.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
244 lines
8.3 KiB
Python
244 lines
8.3 KiB
Python
"""
|
|
Metadata manager for voice samples and cache information.
|
|
|
|
Provides a unified interface for managing metadata.json with
|
|
concurrency safety and data consistency across multiple processes.
|
|
"""
|
|
|
|
import fcntl
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MetadataManager:
|
|
"""
|
|
Manages metadata for uploaded speakers and cache information.
|
|
|
|
Features:
|
|
1. Single source of truth for metadata
|
|
2. Concurrency safety with threading locks
|
|
3. Atomic read-modify-write operations
|
|
4. Merge updates to preserve fields from different components
|
|
"""
|
|
|
|
def __init__(self, metadata_file: Path):
|
|
"""
|
|
Initialize the metadata manager.
|
|
|
|
Args:
|
|
metadata_file: Path to metadata.json file
|
|
"""
|
|
self.metadata_file = metadata_file
|
|
self._lock = threading.Lock() # For intra-process concurrency
|
|
self._metadata = self._load_from_disk()
|
|
|
|
# Create lock file for cross-process synchronization
|
|
self.lock_file = metadata_file.with_suffix(".lock")
|
|
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _load_from_disk(self) -> dict[str, Any]:
|
|
"""Load metadata from disk."""
|
|
if not self.metadata_file.exists():
|
|
return {"uploaded_speakers": {}}
|
|
|
|
try:
|
|
with open(self.metadata_file) as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load metadata from {self.metadata_file}: {e}")
|
|
return {"uploaded_speakers": {}}
|
|
|
|
def _save_to_disk(self, metadata: dict[str, Any]) -> bool:
|
|
"""Save metadata to disk."""
|
|
try:
|
|
self.metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = self.metadata_file.with_suffix(".tmp")
|
|
with open(tmp, "w") as f:
|
|
json.dump(metadata, f, indent=2)
|
|
tmp.replace(self.metadata_file)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to save metadata to {self.metadata_file}: {e}")
|
|
return False
|
|
|
|
# ================================
|
|
# Core fix: single flock overwrites RMW
|
|
# ================================
|
|
def _update_with_file_lock(
|
|
self, update_fn: Callable[[dict[str, Any]], dict[str, Any] | None]
|
|
) -> dict[str, Any] | None:
|
|
lock_fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR)
|
|
try:
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
|
|
metadata = self._load_from_disk()
|
|
result = update_fn(metadata)
|
|
if result is None:
|
|
return None
|
|
|
|
if not self._save_to_disk(metadata):
|
|
return None
|
|
|
|
self._metadata = metadata
|
|
return result
|
|
finally:
|
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
os.close(lock_fd)
|
|
|
|
def get_uploaded_speakers(self) -> dict[str, dict[str, Any]]:
|
|
"""Get all uploaded speakers."""
|
|
# Read directly from disk to ensure getting the latest data
|
|
metadata = self._load_from_disk()
|
|
return metadata.get("uploaded_speakers", {}).copy()
|
|
|
|
def get_speaker(self, speaker_key: str) -> dict[str, Any] | None:
|
|
"""Get specific speaker information."""
|
|
# Read directly from disk to ensure getting the latest data
|
|
metadata = self._load_from_disk()
|
|
speakers = metadata.get("uploaded_speakers", {})
|
|
return speakers.get(speaker_key, {}).copy() if speaker_key in speakers else None
|
|
|
|
def update_speaker(self, speaker_key: str, updates: dict[str, Any]) -> bool:
|
|
"""
|
|
Update speaker information with merge semantics.
|
|
|
|
Uses file locking for cross-process atomic operations.
|
|
"""
|
|
with self._lock:
|
|
|
|
def _update(metadata: dict[str, Any]):
|
|
speakers = metadata.setdefault("uploaded_speakers", {})
|
|
entry = speakers.get(speaker_key, {})
|
|
entry.update(updates)
|
|
speakers[speaker_key] = entry
|
|
return True
|
|
|
|
return self._update_with_file_lock(_update) is not None
|
|
|
|
def create_speaker(self, speaker_key: str, speaker_data: dict[str, Any]) -> bool:
|
|
"""
|
|
Create a new speaker entry.
|
|
|
|
Uses file locking for cross-process atomic operations.
|
|
"""
|
|
with self._lock:
|
|
|
|
def _create(metadata: dict[str, Any]):
|
|
speakers = metadata.setdefault("uploaded_speakers", {})
|
|
if speaker_key in speakers:
|
|
logger.warning(f"Speaker {speaker_key} already exists")
|
|
return None
|
|
speakers[speaker_key] = speaker_data
|
|
return True
|
|
|
|
return self._update_with_file_lock(_create) is not None
|
|
|
|
def update_cache_info(self, speaker_key: str, cache_file_path: Path, status: str = "ready") -> bool:
|
|
"""
|
|
Update cache information for a speaker.
|
|
"""
|
|
updates = {
|
|
"cache_status": status,
|
|
"cache_file": str(cache_file_path),
|
|
"cache_generated_at": time.time(),
|
|
}
|
|
return self.update_speaker(speaker_key, updates)
|
|
|
|
def delete_speaker(self, speaker_key: str) -> dict[str, Any] | None:
|
|
"""
|
|
Delete a speaker from metadata and clean up associated files.
|
|
|
|
Uses file locking for cross-process atomic operations.
|
|
|
|
Args:
|
|
speaker_key: Speaker name (lowercase)
|
|
base_dir: Base directory for file validation (optional)
|
|
|
|
Returns:
|
|
dict: Deleted speaker information if successful, None if speaker doesn't exist or error
|
|
"""
|
|
with self._lock:
|
|
|
|
def _delete(metadata: dict[str, Any]):
|
|
speakers = metadata.get("uploaded_speakers", {})
|
|
if speaker_key not in speakers:
|
|
logger.warning(f"Speaker {speaker_key} not found in metadata")
|
|
return None
|
|
|
|
speaker_info = speakers.pop(speaker_key)
|
|
|
|
# Clean up associated files
|
|
deleted_files = self._cleanup_speaker_files(speaker_info)
|
|
if deleted_files:
|
|
logger.info(f"Deleted {len(deleted_files)} files for speaker {speaker_key}: {deleted_files}")
|
|
|
|
return speaker_info
|
|
|
|
return self._update_with_file_lock(_delete)
|
|
|
|
def _cleanup_speaker_files(self, speaker_info: dict[str, Any]) -> list[str]:
|
|
"""
|
|
Clean up files associated with a speaker.
|
|
|
|
Args:
|
|
speaker_info: Speaker information dictionary
|
|
base_dir: Base directory for file validation (optional)
|
|
|
|
Returns:
|
|
list: List of successfully deleted file paths
|
|
"""
|
|
deleted_files = []
|
|
|
|
# Helper function to safely delete a file
|
|
def safe_delete(file_path_str: str, description: str) -> bool:
|
|
if not file_path_str:
|
|
return False
|
|
|
|
try:
|
|
file_path = Path(file_path_str)
|
|
|
|
# Check if file exists
|
|
if not file_path.exists():
|
|
logger.debug(f"{description} not found: {file_path}")
|
|
return False
|
|
|
|
# Delete the file
|
|
file_path.unlink()
|
|
logger.info(f"Deleted {description}: {file_path}")
|
|
deleted_files.append(str(file_path))
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete {description} {file_path_str}: {e}")
|
|
return False
|
|
|
|
# Delete audio file
|
|
audio_file = speaker_info.get("file_path")
|
|
if audio_file:
|
|
safe_delete(audio_file, "audio file")
|
|
|
|
# Delete cache file
|
|
cache_file = speaker_info.get("cache_file")
|
|
if cache_file:
|
|
safe_delete(cache_file, "cache file")
|
|
|
|
return deleted_files
|
|
|
|
def reload_from_disk(self) -> bool:
|
|
"""Force reload metadata from disk (useful for external changes)."""
|
|
with self._lock:
|
|
try:
|
|
self._metadata = self._load_from_disk()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to reload metadata from disk: {e}")
|
|
return False
|