mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 22:22:27 +02:00
627 lines
19 KiB
Python
627 lines
19 KiB
Python
# streaming_providers/base/utils/vfs.py
|
|
"""
|
|
Virtual File System abstraction layer
|
|
Provides transparent file operations for both Kodi and regular Python environments
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import threading
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
# Import centralized environment manager
|
|
from .environment import get_environment_manager, is_kodi_environment
|
|
|
|
# Import centralized logger
|
|
from .logger import logger
|
|
|
|
|
|
class VFS:
|
|
"""
|
|
Virtual File System abstraction layer
|
|
|
|
Provides a unified interface for file operations that works in both
|
|
Kodi addon environments and standard Python environments.
|
|
"""
|
|
|
|
def __init__(self, config_dir: Optional[str] = None, addon_subdir: str = ""):
|
|
"""
|
|
Initialize VFS handler with explicit config directory support
|
|
|
|
Args:
|
|
config_dir: Optional explicit config directory (overrides automatic detection)
|
|
addon_subdir: Optional subdirectory within addon data (for organization)
|
|
"""
|
|
self.addon_subdir = addon_subdir
|
|
self._base_path = None
|
|
self._explicit_config_dir = config_dir
|
|
self._env_manager = get_environment_manager()
|
|
|
|
logger.debug(
|
|
f"VFS initialized with config_dir={config_dir}, addon_subdir={addon_subdir}"
|
|
)
|
|
|
|
@property
|
|
def base_path(self) -> str:
|
|
"""Get the base path for file operations"""
|
|
if self._base_path is None:
|
|
if self._explicit_config_dir:
|
|
# Use explicitly provided config directory
|
|
self._base_path = self._explicit_config_dir
|
|
logger.info(f"Using explicit config directory: {self._base_path}")
|
|
else:
|
|
# Use profile path from environment manager
|
|
profile_path = self._env_manager.get_config("profile_path", "")
|
|
if self.addon_subdir:
|
|
if is_kodi_environment():
|
|
# Kodi uses forward slashes
|
|
self._base_path = os.path.join(
|
|
profile_path, self.addon_subdir
|
|
).replace("\\", "/")
|
|
else:
|
|
# Standard filesystem
|
|
self._base_path = os.path.join(profile_path, self.addon_subdir)
|
|
else:
|
|
self._base_path = profile_path
|
|
|
|
logger.info(f"Base path from environment: {self._base_path}")
|
|
|
|
# Ensure base directory exists. self._base_path is already assigned
|
|
# above, so the join_path() call inside mkdirs() will not recurse
|
|
# back into this property.
|
|
self.mkdirs("")
|
|
|
|
return self._base_path
|
|
|
|
def join_path(self, *parts) -> str:
|
|
"""
|
|
Join path components using appropriate separator for environment
|
|
|
|
Args:
|
|
*parts: Path components to join
|
|
|
|
Returns:
|
|
Joined path string
|
|
"""
|
|
if is_kodi_environment():
|
|
# Kodi VFS uses forward slashes
|
|
path = self.base_path
|
|
for part in parts:
|
|
if part:
|
|
path = path.rstrip("/") + "/" + str(part).lstrip("/")
|
|
return path
|
|
else:
|
|
# Use os.path.join for standard filesystem
|
|
return os.path.join(self.base_path, *[str(p) for p in parts if p])
|
|
|
|
def exists(self, filepath: str) -> bool:
|
|
"""
|
|
Check if file or directory exists
|
|
|
|
Args:
|
|
filepath: Path to check (relative to base_path or absolute)
|
|
|
|
Returns:
|
|
True if exists, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.isabs(filepath):
|
|
filepath = self.join_path(filepath)
|
|
|
|
if is_kodi_environment():
|
|
import xbmcvfs
|
|
|
|
return xbmcvfs.exists(filepath)
|
|
else:
|
|
import pathlib
|
|
|
|
return pathlib.Path(filepath).exists()
|
|
except Exception as e:
|
|
logger.error(f"Error checking if {filepath} exists: {e}")
|
|
return False
|
|
|
|
def mkdirs(self, dirpath: str) -> bool:
|
|
"""
|
|
Create directory and all parent directories
|
|
|
|
Args:
|
|
dirpath: Directory path to create
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.isabs(dirpath):
|
|
dirpath = self.join_path(dirpath)
|
|
|
|
if is_kodi_environment():
|
|
import xbmcvfs
|
|
|
|
if not xbmcvfs.exists(dirpath):
|
|
result = xbmcvfs.mkdirs(dirpath)
|
|
logger.debug(f"Kodi mkdirs {dirpath}: {result}")
|
|
return result
|
|
return True
|
|
else:
|
|
import pathlib
|
|
|
|
pathlib.Path(dirpath).mkdir(parents=True, exist_ok=True)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error creating directory {dirpath}: {e}")
|
|
return False
|
|
|
|
def read_text(self, filepath: str, encoding: str = "utf-8") -> Optional[str]:
|
|
"""
|
|
Read text content from file
|
|
|
|
Args:
|
|
filepath: File path to read
|
|
encoding: Text encoding (ignored in Kodi VFS)
|
|
|
|
Returns:
|
|
File content as string or None if error/not found
|
|
"""
|
|
try:
|
|
if not os.path.isabs(filepath):
|
|
filepath = self.join_path(filepath)
|
|
|
|
if is_kodi_environment():
|
|
import xbmcvfs
|
|
|
|
if not xbmcvfs.exists(filepath):
|
|
return None
|
|
with xbmcvfs.File(filepath, "r") as f:
|
|
content = f.read()
|
|
return content if content else None
|
|
else:
|
|
import pathlib
|
|
|
|
path = pathlib.Path(filepath)
|
|
if not path.exists():
|
|
return None
|
|
with open(path, "r", encoding=encoding) as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
logger.error(f"Error reading file {filepath}: {e}")
|
|
return None
|
|
|
|
def write_text(self, filepath: str, content: str, encoding: str = "utf-8") -> bool:
|
|
"""
|
|
Write text content to file
|
|
|
|
Args:
|
|
filepath: File path to write
|
|
content: Text content to write
|
|
encoding: Text encoding (ignored in Kodi VFS)
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.isabs(filepath):
|
|
filepath = self.join_path(filepath)
|
|
|
|
if is_kodi_environment():
|
|
import xbmcvfs
|
|
|
|
# Ensure directory exists
|
|
dir_path = "/".join(filepath.split("/")[:-1])
|
|
if dir_path and not xbmcvfs.exists(dir_path):
|
|
xbmcvfs.mkdirs(dir_path)
|
|
|
|
with xbmcvfs.File(filepath, "w") as f:
|
|
bytes_written = f.write(content)
|
|
logger.debug(
|
|
f"Kodi file write: {bytes_written} bytes to {filepath}"
|
|
)
|
|
return bytes_written > 0
|
|
else:
|
|
import pathlib
|
|
|
|
path = pathlib.Path(filepath)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "w", encoding=encoding) as f:
|
|
f.write(content)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error writing file {filepath}: {e}")
|
|
return False
|
|
|
|
def write_binary(self, filepath: str, data: bytes) -> bool:
|
|
"""
|
|
Write binary content to file
|
|
|
|
Args:
|
|
filepath: File path to write
|
|
data: Raw bytes to write
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.isabs(filepath):
|
|
filepath = self.join_path(filepath)
|
|
|
|
if is_kodi_environment():
|
|
import xbmcvfs
|
|
|
|
dir_path = "/".join(filepath.split("/")[:-1])
|
|
if dir_path and not xbmcvfs.exists(dir_path):
|
|
xbmcvfs.mkdirs(dir_path)
|
|
|
|
with xbmcvfs.File(filepath, "w") as f:
|
|
bytes_written = f.write(bytearray(data))
|
|
logger.debug(
|
|
f"Kodi binary file write: {bytes_written} bytes to {filepath}"
|
|
)
|
|
return bytes_written > 0
|
|
else:
|
|
import pathlib
|
|
|
|
path = pathlib.Path(filepath)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "wb") as f:
|
|
f.write(data)
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error writing binary file {filepath}: {e}")
|
|
return False
|
|
|
|
def delete(self, filepath: str) -> bool:
|
|
"""
|
|
Delete file
|
|
|
|
Args:
|
|
filepath: File path to delete
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.isabs(filepath):
|
|
filepath = self.join_path(filepath)
|
|
|
|
if is_kodi_environment():
|
|
import xbmcvfs
|
|
|
|
if xbmcvfs.exists(filepath):
|
|
return xbmcvfs.delete(filepath)
|
|
return True
|
|
else:
|
|
import pathlib
|
|
|
|
path = pathlib.Path(filepath)
|
|
if path.exists():
|
|
path.unlink()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error deleting file {filepath}: {e}")
|
|
return False
|
|
|
|
def read_json(self, filepath: str) -> Optional[dict]:
|
|
"""
|
|
Read and parse JSON file
|
|
|
|
Args:
|
|
filepath: JSON file path to read
|
|
|
|
Returns:
|
|
Parsed JSON data or None if error/not found
|
|
"""
|
|
try:
|
|
content = self.read_text(filepath)
|
|
if content:
|
|
return json.loads(content)
|
|
return None
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Invalid JSON in {filepath}: {e}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Error reading JSON file {filepath}: {e}")
|
|
return None
|
|
|
|
def write_json(self, filepath: str, data: Any, indent: int = 2) -> bool:
|
|
"""
|
|
Write data to JSON file
|
|
|
|
Args:
|
|
filepath: JSON file path to write
|
|
data: Data to serialize as JSON
|
|
indent: JSON indentation level
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
content = json.dumps(data, indent=indent, ensure_ascii=False, default=str)
|
|
return self.write_text(filepath, content)
|
|
except Exception as e:
|
|
logger.error(f"Error writing JSON file {filepath}: {e}")
|
|
return False
|
|
|
|
def list_files(self, dirpath: str = "", pattern: str = "*") -> List[str]:
|
|
"""
|
|
List files in directory
|
|
|
|
Args:
|
|
dirpath: Directory path to list (relative to base_path)
|
|
pattern: File pattern filter (basic glob patterns)
|
|
|
|
Returns:
|
|
List of filenames (basenames only, files only — not subdirectories)
|
|
"""
|
|
try:
|
|
if not dirpath:
|
|
dirpath = self.base_path
|
|
elif not os.path.isabs(dirpath):
|
|
dirpath = self.join_path(dirpath)
|
|
|
|
if is_kodi_environment():
|
|
import fnmatch
|
|
|
|
import xbmcvfs
|
|
|
|
if not xbmcvfs.exists(dirpath):
|
|
return []
|
|
|
|
dirs, files = xbmcvfs.listdir(dirpath)
|
|
# Basic pattern matching (only supports * wildcard)
|
|
if pattern == "*":
|
|
return files
|
|
else:
|
|
# Simple pattern matching
|
|
return [f for f in files if fnmatch.fnmatch(f, pattern)]
|
|
else:
|
|
import pathlib
|
|
|
|
path = pathlib.Path(dirpath)
|
|
if not path.exists() or not path.is_dir():
|
|
return []
|
|
|
|
if pattern == "*":
|
|
return [f.name for f in path.iterdir() if f.is_file()]
|
|
else:
|
|
return [f.name for f in path.glob(pattern) if f.is_file()]
|
|
except Exception as e:
|
|
logger.error(f"Error listing files in {dirpath}: {e}")
|
|
return []
|
|
|
|
def listdir(self, dirpath: str = "") -> List[str]:
|
|
"""
|
|
Alias for list_files(dirpath) with the default "*" pattern.
|
|
|
|
Several call sites (e.g. MPDCacheManager.clear_all /
|
|
clear_expired) call self.vfs.listdir() rather than
|
|
self.vfs.list_files() — this previously did not exist on VFS at
|
|
all and raised AttributeError on every call, which was silently
|
|
swallowed by those callers' broad except blocks. Kept as an
|
|
explicit method (not just a `listdir = list_files` assignment)
|
|
so it shows up in stack traces under its own name.
|
|
|
|
Args:
|
|
dirpath: Directory path to list (relative to base_path)
|
|
|
|
Returns:
|
|
List of filenames (basenames only, files only)
|
|
"""
|
|
return self.list_files(dirpath)
|
|
|
|
def get_size(self, filepath: str) -> Optional[int]:
|
|
"""
|
|
Get file size in bytes
|
|
|
|
Args:
|
|
filepath: File path
|
|
|
|
Returns:
|
|
File size in bytes or None if error/not found
|
|
"""
|
|
try:
|
|
if not os.path.isabs(filepath):
|
|
filepath = self.join_path(filepath)
|
|
|
|
if is_kodi_environment():
|
|
import xbmcvfs
|
|
|
|
if not xbmcvfs.exists(filepath):
|
|
return None
|
|
stat = xbmcvfs.Stat(filepath)
|
|
return stat.st_size()
|
|
else:
|
|
import pathlib
|
|
|
|
path = pathlib.Path(filepath)
|
|
if not path.exists():
|
|
return None
|
|
return path.stat().st_size
|
|
except Exception as e:
|
|
logger.error(f"Error getting size of {filepath}: {e}")
|
|
return None
|
|
|
|
def ensure_directory(self, filepath: str) -> bool:
|
|
"""
|
|
Ensure directory for filepath exists
|
|
|
|
Args:
|
|
filepath: File path to ensure directory for
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not os.path.isabs(filepath):
|
|
filepath = self.join_path(filepath)
|
|
|
|
dir_path = os.path.dirname(filepath)
|
|
return self.mkdirs(dir_path)
|
|
except Exception as e:
|
|
logger.error(f"Error ensuring directory for {filepath}: {e}")
|
|
return False
|
|
|
|
def debug_info(self) -> dict:
|
|
"""
|
|
Get debug information about the VFS environment
|
|
|
|
Returns:
|
|
Dictionary with debug information
|
|
"""
|
|
info = {
|
|
"kodi_available": is_kodi_environment(),
|
|
"base_path": self.base_path,
|
|
"base_path_exists": self.exists(""),
|
|
"explicit_config_dir": self._explicit_config_dir,
|
|
"addon_subdir": self.addon_subdir,
|
|
"environment": self._env_manager.get_environment(),
|
|
}
|
|
|
|
if is_kodi_environment():
|
|
try:
|
|
import xbmc
|
|
import xbmcaddon
|
|
|
|
addon = xbmcaddon.Addon()
|
|
info.update(
|
|
{
|
|
"addon_id": addon.getAddonInfo("id"),
|
|
"addon_version": addon.getAddonInfo("version"),
|
|
"kodi_version": xbmc.getInfoLabel("System.BuildVersion"),
|
|
}
|
|
)
|
|
except Exception as e:
|
|
info["kodi_error"] = str(e)
|
|
|
|
return info
|
|
|
|
|
|
# Cache for VFS instances with different configurations.
|
|
# Guarded by a lock: this backend runs multi-threaded (Bottle), and without
|
|
# it two threads racing on the same not-yet-cached key could each construct
|
|
# and briefly use their own VFS instance for the same directory. Functionally
|
|
# harmless (both point at the same base_path), but it defeats the point of
|
|
# caching and is cheap to close off properly.
|
|
_vfs_cache: Dict[Tuple[Optional[str], str], "VFS"] = {}
|
|
_vfs_cache_lock = threading.Lock()
|
|
|
|
|
|
def get_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> "VFS":
|
|
"""
|
|
Get VFS instance for specific configuration
|
|
|
|
Uses a cache to avoid creating multiple instances with same configuration
|
|
|
|
Args:
|
|
config_dir: Optional explicit config directory
|
|
addon_subdir: Optional subdirectory within addon data
|
|
|
|
Returns:
|
|
VFS instance
|
|
"""
|
|
cache_key = (config_dir, addon_subdir)
|
|
|
|
# Fast path without the lock for the common case (already cached).
|
|
vfs = _vfs_cache.get(cache_key)
|
|
if vfs is not None:
|
|
return vfs
|
|
|
|
with _vfs_cache_lock:
|
|
# Re-check inside the lock in case another thread created it while
|
|
# we were waiting.
|
|
vfs = _vfs_cache.get(cache_key)
|
|
if vfs is None:
|
|
vfs = VFS(config_dir, addon_subdir)
|
|
_vfs_cache[cache_key] = vfs
|
|
return vfs
|
|
|
|
|
|
# For backward compatibility with existing code
|
|
_global_vfs = None
|
|
|
|
|
|
def get_global_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> "VFS":
|
|
"""
|
|
Get global VFS instance (for backward compatibility)
|
|
|
|
Note: Consider using get_vfs() for new code
|
|
"""
|
|
global _global_vfs
|
|
vfs = get_vfs(config_dir, addon_subdir)
|
|
_global_vfs = vfs # Keep reference for backward compatibility
|
|
return vfs
|
|
|
|
|
|
# Convenience functions that use VFS cache
|
|
def exists(
|
|
filepath: str, config_dir: Optional[str] = None, addon_subdir: str = ""
|
|
) -> bool:
|
|
"""Check if file exists"""
|
|
return get_vfs(config_dir, addon_subdir).exists(filepath)
|
|
|
|
|
|
def mkdirs(
|
|
dirpath: str, config_dir: Optional[str] = None, addon_subdir: str = ""
|
|
) -> bool:
|
|
"""Create directories"""
|
|
return get_vfs(config_dir, addon_subdir).mkdirs(dirpath)
|
|
|
|
|
|
def read_text(
|
|
filepath: str,
|
|
encoding: str = "utf-8",
|
|
config_dir: Optional[str] = None,
|
|
addon_subdir: str = "",
|
|
) -> Optional[str]:
|
|
"""Read text file"""
|
|
return get_vfs(config_dir, addon_subdir).read_text(filepath, encoding)
|
|
|
|
|
|
def write_text(
|
|
filepath: str,
|
|
content: str,
|
|
encoding: str = "utf-8",
|
|
config_dir: Optional[str] = None,
|
|
addon_subdir: str = "",
|
|
) -> bool:
|
|
"""Write text file"""
|
|
return get_vfs(config_dir, addon_subdir).write_text(filepath, content, encoding)
|
|
|
|
|
|
def write_binary(
|
|
filepath: str,
|
|
data: bytes,
|
|
config_dir: Optional[str] = None,
|
|
addon_subdir: str = "",
|
|
) -> bool:
|
|
"""Write binary file"""
|
|
return get_vfs(config_dir, addon_subdir).write_binary(filepath, data)
|
|
|
|
|
|
def read_json(
|
|
filepath: str, config_dir: Optional[str] = None, addon_subdir: str = ""
|
|
) -> Optional[dict]:
|
|
"""Read JSON file"""
|
|
return get_vfs(config_dir, addon_subdir).read_json(filepath)
|
|
|
|
|
|
def write_json(
|
|
filepath: str,
|
|
data: Any,
|
|
indent: int = 2,
|
|
config_dir: Optional[str] = None,
|
|
addon_subdir: str = "",
|
|
) -> bool:
|
|
"""Write JSON file"""
|
|
return get_vfs(config_dir, addon_subdir).write_json(filepath, data, indent)
|
|
|
|
|
|
def delete(
|
|
filepath: str, config_dir: Optional[str] = None, addon_subdir: str = ""
|
|
) -> bool:
|
|
"""Delete file"""
|
|
return get_vfs(config_dir, addon_subdir).delete(filepath)
|
|
|
|
|
|
def join_path(*parts, config_dir: Optional[str] = None, addon_subdir: str = "") -> str:
|
|
"""Join path components"""
|
|
vfs = get_vfs(config_dir, addon_subdir)
|
|
return vfs.join_path(*parts) |