Files
script.service.ultimate/lib/streaming_providers/base/utils/vfs.py
T
2025-10-29 20:23:50 +01:00

476 lines
15 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
from typing import Optional, Any, List
from pathlib import Path
# Import centralized logger
from .logger import logger
# Kodi imports - with fallback for non-Kodi environments
try:
import xbmc
import xbmcvfs
import xbmcaddon
KODI_AVAILABLE = True
logger.info("Kodi VFS environment detected")
except ImportError:
KODI_AVAILABLE = False
logger.info("Standard filesystem environment detected")
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
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}")
elif KODI_AVAILABLE:
# Use Kodi's addon data directory
try:
addon = xbmcaddon.Addon()
# Use xbmcvfs.translatePath instead of xbmc.translatePath for Kodi 19+
addon_profile = xbmcvfs.translatePath(addon.getAddonInfo('profile'))
if self.addon_subdir:
self._base_path = os.path.join(addon_profile, self.addon_subdir).replace('\\', '/')
else:
self._base_path = addon_profile.replace('\\', '/')
logger.info(f"Kodi base path: {self._base_path}")
except Exception as e:
logger.error(f"Error getting Kodi addon path: {e}")
# Fallback to temp directory
try:
self._base_path = xbmcvfs.translatePath("special://temp/streaming_providers")
except:
self._base_path = "/tmp/streaming_providers"
else:
# Use standard filesystem
if self.addon_subdir:
self._base_path = str(Path.home() / '.streaming_providers' / self.addon_subdir)
else:
self._base_path = str(Path.home() / '.streaming_providers')
logger.info(f"Standard filesystem base path: {self._base_path}")
# Ensure base directory exists
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 KODI_AVAILABLE:
# 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 pathlib for standard filesystem
path = Path(self.base_path)
for part in parts:
if part:
path = path / str(part)
return str(path)
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 KODI_AVAILABLE:
return xbmcvfs.exists(filepath)
else:
return 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 KODI_AVAILABLE:
if not xbmcvfs.exists(dirpath):
result = xbmcvfs.mkdirs(dirpath)
logger.debug(f"Kodi mkdirs {dirpath}: {result}")
return result
return True
else:
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 KODI_AVAILABLE:
if not xbmcvfs.exists(filepath):
return None
with xbmcvfs.File(filepath, 'r') as f:
content = f.read()
return content if content else None
else:
path = 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 KODI_AVAILABLE:
# 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:
path = 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 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 KODI_AVAILABLE:
if xbmcvfs.exists(filepath):
return xbmcvfs.delete(filepath)
return True
else:
path = 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
"""
try:
if not dirpath:
dirpath = self.base_path
elif not os.path.isabs(dirpath):
dirpath = self.join_path(dirpath)
if KODI_AVAILABLE:
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
import fnmatch
return [f for f in files if fnmatch.fnmatch(f, pattern)]
else:
path = 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 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 KODI_AVAILABLE:
if not xbmcvfs.exists(filepath):
return None
stat = xbmcvfs.Stat(filepath)
return stat.st_size()
else:
path = 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': KODI_AVAILABLE,
'base_path': self.base_path,
'base_path_exists': self.exists(''),
'explicit_config_dir': self._explicit_config_dir,
'addon_subdir': self.addon_subdir
}
if KODI_AVAILABLE:
try:
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
# Convenience functions for global VFS instance
_global_vfs = None
def get_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> VFS:
"""
Get global VFS instance
Args:
config_dir: Optional explicit config directory
addon_subdir: Optional subdirectory within addon data
Returns:
VFS instance
"""
global _global_vfs
if _global_vfs is None or _global_vfs._explicit_config_dir != config_dir or _global_vfs.addon_subdir != addon_subdir:
_global_vfs = VFS(config_dir, addon_subdir)
return _global_vfs
# Convenience functions that use global VFS
def exists(filepath: str, config_dir: Optional[str] = None) -> bool:
"""Check if file exists"""
return get_vfs(config_dir).exists(filepath)
def mkdirs(dirpath: str, config_dir: Optional[str] = None) -> bool:
"""Create directories"""
return get_vfs(config_dir).mkdirs(dirpath)
def read_text(filepath: str, encoding: str = 'utf-8', config_dir: Optional[str] = None) -> Optional[str]:
"""Read text file"""
return get_vfs(config_dir).read_text(filepath, encoding)
def write_text(filepath: str, content: str, encoding: str = 'utf-8', config_dir: Optional[str] = None) -> bool:
"""Write text file"""
return get_vfs(config_dir).write_text(filepath, content, encoding)
def read_json(filepath: str, config_dir: Optional[str] = None) -> Optional[dict]:
"""Read JSON file"""
return get_vfs(config_dir).read_json(filepath)
def write_json(filepath: str, data: Any, indent: int = 2, config_dir: Optional[str] = None) -> bool:
"""Write JSON file"""
return get_vfs(config_dir).write_json(filepath, data, indent)
def delete(filepath: str, config_dir: Optional[str] = None) -> bool:
"""Delete file"""
return get_vfs(config_dir).delete(filepath)
def join_path(*parts, config_dir: Optional[str] = None) -> str:
"""Join path components"""
vfs = get_vfs(config_dir)
return vfs.join_path(*parts)