update for non-blocking

This commit is contained in:
Nirvana
2026-10-22 09:19:41 +02:00
parent 9d23cd8ac7
commit e4e1b44f08
11 changed files with 477 additions and 95 deletions
@@ -1,4 +1,5 @@
# streaming_providers/base/auth/session_manager.py
import functools
import time
import uuid
from typing import Any, Dict, Optional
@@ -14,6 +15,18 @@ from .base_auth import BaseAuthToken
_SENSITIVE_KEY_PARTS = ("token", "secret", "password", "jwt", "serial", "device_number")
def _locked(method):
"""Run a read-modify-write method while holding session.json's process-wide
lock, so concurrent request threads cannot overwrite each other's changes."""
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
with self._file_lock:
return method(self, *args, **kwargs)
return wrapper
class SessionManager:
"""
Manages persistent session data including tokens and device IDs
@@ -41,6 +54,9 @@ class SessionManager:
# Ensure base directory exists
self.vfs.mkdirs("")
# Shared by every SessionManager/VFS instance that targets this file
self._file_lock = self.vfs.file_lock(self.session_file)
logger.debug(f"SessionManager initialized with VFS base: {self.vfs.base_path}")
logger.debug(f"Session file: {self.vfs.join_path(self.session_file)}")
@@ -107,6 +123,7 @@ class SessionManager:
logger.error(f"Error loading session for {provider}{country_str}: {e}")
return None
@_locked
def save_session(
self, provider: str, session_data: Dict[str, Any], country: Optional[str] = None
) -> bool:
@@ -210,6 +227,7 @@ class SessionManager:
logger.error(f"Full traceback: {traceback.format_exc()}")
return False
@_locked
def save_scoped_token(
self,
provider: str,
@@ -432,6 +450,7 @@ class SessionManager:
# No refresh token or no expiration info
return False
@_locked
def save_token(
self, provider: str, token: BaseAuthToken, country: Optional[str] = None
) -> bool:
@@ -527,6 +546,7 @@ class SessionManager:
return device_id
@_locked
def clear_session(self, provider: str, country: Optional[str] = None) -> bool:
"""Clear session data for a provider and optional country"""
country_str = f" (country: {country})" if country else ""
@@ -569,6 +589,7 @@ class SessionManager:
logger.error(f"Error clearing session for {provider}{country_str}: {e}")
return False
@_locked
def clear_scoped_token(self, provider: str, scope: str, country: Optional[str] = None) -> bool:
"""
Clear token for a specific scope
@@ -601,6 +622,7 @@ class SessionManager:
logger.error(f"Error clearing scoped token for {provider}{country_str}/{scope}: {e}")
return False
@_locked
def clear_token(self, provider: str, country: Optional[str] = None) -> bool:
"""Clear only token data but keep other session data (legacy compatibility)"""
country_str = f" (country: {country})" if country else ""
+34
View File
@@ -147,6 +147,40 @@ class SessionExpiredError(AuthError):
"""Server-side session is gone; a full re-login is required."""
class AuthPendingError(AuthError):
"""
An interactive login (QR / device code) has been started and is waiting
for the user. Not a failure: the caller should show the code and retry.
Subclasses AuthError so generic "needs login" handling still applies;
HTTP layers should check for it first and answer 202 with the payload.
Attributes:
login_code: short code for manual entry
qr_target_url: URL to encode in a QR code (may be None)
remaining_seconds: time left before the code expires
retry_after: suggested seconds before the caller retries
"""
def __init__(
self,
message: str = "Interactive login pending",
*,
login_code: Optional[str] = None,
qr_target_url: Optional[str] = None,
remaining_seconds: Optional[int] = None,
retry_after: Optional[int] = None,
status: Optional[int] = None,
url: Optional[str] = None,
code: Optional[str] = None,
) -> None:
super().__init__(message, status=status, url=url, code=code)
self.login_code = login_code
self.qr_target_url = qr_target_url
self.remaining_seconds = remaining_seconds
self.retry_after = retry_after
# ---------------------------------------------------------------------------
# Access control
# ---------------------------------------------------------------------------
+103
View File
@@ -0,0 +1,103 @@
# streaming_providers/base/http_errors.py
"""
Transport-agnostic mapping from the shared provider error hierarchy
(base/errors.py) to an HTTP status and a JSON-serialisable body.
Kept free of any web-framework import so it can be unit-tested and reused;
the route layer applies the result to its own response object.
Rules:
* Errors that tell the CLIENT something actionable (auth, entitlement,
not found, bad request, geo-block, rate limit, pending login) echo the
provider's message.
* Upstream/server-side failures and unknown exceptions return a generic
message. Their detail (which can contain URLs with tokens) is for the
log only - see `log_detail`.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
from .errors import (
AccountRestrictedError,
AuthError,
AuthPendingError,
BadRequestError,
CatchupRequiredError,
ConfigurationError,
EntitlementError,
GeoBlockError,
NotFoundError,
NotImplementedYetError,
OperationFailedError,
PlaybackRestrictedError,
ProviderError,
RateLimitError,
ServerError,
)
# Most specific first: the first isinstance() match wins.
_STATUS_BY_TYPE: Tuple[Tuple[type, int, bool], ...] = (
# (exception type, HTTP status, echo provider message to client?)
(AuthPendingError, 202, True),
(AuthError, 401, True),
(GeoBlockError, 451, True),
(AccountRestrictedError, 403, True),
(EntitlementError, 403, True),
(PlaybackRestrictedError, 403, True),
(NotFoundError, 404, True),
(BadRequestError, 400, True),
(RateLimitError, 429, True),
(CatchupRequiredError, 409, True),
(NotImplementedYetError, 501, True), # also UnsupportedOperationError
(ConfigurationError, 503, True),
(ServerError, 502, False), # also TransportError
(OperationFailedError, 502, False),
(ProviderError, 502, False),
)
GENERIC_UPSTREAM_MESSAGE = "Upstream provider error"
GENERIC_INTERNAL_MESSAGE = "Internal server error"
def error_to_http(exc: BaseException) -> Tuple[int, Dict[str, Any], Dict[str, str]]:
"""
Returns (status, body, headers) for an exception.
A pending login is a normal outcome, so it maps to 202 with everything a
client needs to display the code, plus a Retry-After header.
"""
headers: Dict[str, str] = {}
if isinstance(exc, AuthPendingError):
body: Dict[str, Any] = {
"status": "login_pending",
"login_code": exc.login_code,
"qr_target_url": exc.qr_target_url,
"remaining_seconds": exc.remaining_seconds,
}
if exc.retry_after:
headers["Retry-After"] = str(int(exc.retry_after))
return 202, body, headers
for exc_type, status, echo in _STATUS_BY_TYPE:
if isinstance(exc, exc_type):
message = str(exc) if echo else GENERIC_UPSTREAM_MESSAGE
body = {"error": message, "error_type": type(exc).__name__}
code = getattr(exc, "code", None)
if code is not None:
body["code"] = code
if isinstance(exc, RateLimitError) and exc.retry_after:
headers["Retry-After"] = str(int(exc.retry_after))
return status, body, headers
return 500, {"error": GENERIC_INTERNAL_MESSAGE}, headers
def log_detail(exc: BaseException, context: str) -> Optional[str]:
"""Message for the server log, or None if nothing should be logged
(a pending login is not an error)."""
if isinstance(exc, AuthPendingError):
return None
return f"{context}: {type(exc).__name__}: {exc}"
@@ -32,23 +32,25 @@ class ProviderSettingsStore:
return country_data.get(key, default)
def set(self, provider: str, country: str, key: str, value: Any) -> bool:
data = self.vfs.read_json(self.FILE) or {}
provider_data = data.get(provider)
if not isinstance(provider_data, dict):
provider_data = data[provider] = {}
country_data = provider_data.get(country)
if not isinstance(country_data, dict):
country_data = provider_data[country] = {}
country_data[key] = value
ok = self.vfs.write_json(self.FILE, data)
with self.vfs.file_lock(self.FILE):
data = self.vfs.read_json(self.FILE) or {}
provider_data = data.get(provider)
if not isinstance(provider_data, dict):
provider_data = data[provider] = {}
country_data = provider_data.get(country)
if not isinstance(country_data, dict):
country_data = provider_data[country] = {}
country_data[key] = value
ok = self.vfs.write_json(self.FILE, data)
if not ok:
logger.error(f"Failed to write {self.FILE}")
return ok
def clear(self, provider: str, country: str, key: str) -> bool:
data = self.vfs.read_json(self.FILE) or {}
country_data = (data.get(provider) or {}).get(country)
if isinstance(country_data, dict) and key in country_data:
del country_data[key]
return self.vfs.write_json(self.FILE, data)
return True
with self.vfs.file_lock(self.FILE):
data = self.vfs.read_json(self.FILE) or {}
country_data = (data.get(provider) or {}).get(country)
if isinstance(country_data, dict) and key in country_data:
del country_data[key]
return self.vfs.write_json(self.FILE, data)
return True
+98 -33
View File
@@ -6,6 +6,7 @@ Provides transparent file operations for both Kodi and regular Python environmen
import json
import os
import tempfile
import threading
from typing import Any, Dict, List, Optional, Tuple
@@ -16,6 +17,23 @@ from .environment import get_environment_manager, is_kodi_environment
from .logger import logger
# One re-entrant lock per absolute file path, shared by ALL VFS instances in
# the process (SessionManager, the provider's own SessionManager and the
# authenticator's settings manager each own a separate VFS pointing at the
# same session.json, so a per-instance lock would not protect anything).
_file_locks: Dict[str, "threading.RLock"] = {}
_file_locks_guard = threading.Lock()
def _lock_for_path(abs_path: str) -> "threading.RLock":
key = os.path.normcase(os.path.abspath(abs_path)) if "://" not in abs_path else abs_path
lock = _file_locks.get(key)
if lock is None:
with _file_locks_guard:
lock = _file_locks.setdefault(key, threading.RLock())
return lock
class VFS:
"""
Virtual File System abstraction layer
@@ -151,6 +169,21 @@ class VFS:
logger.error(f"Error creating directory {dirpath}: {e}")
return False
def file_lock(self, filepath: str) -> "threading.RLock":
"""
Process-wide re-entrant lock for one file. Hold it around a whole
read-modify-write sequence so concurrent request threads cannot lose
each other's updates:
with vfs.file_lock("session.json"):
data = vfs.read_json("session.json") or {}
data["x"] = 1
vfs.write_json("session.json", data)
"""
if not os.path.isabs(filepath):
filepath = self.join_path(filepath)
return _lock_for_path(filepath)
def read_text(self, filepath: str, encoding: str = "utf-8") -> Optional[str]:
"""
Read text content from file
@@ -166,22 +199,23 @@ class VFS:
if not os.path.isabs(filepath):
filepath = self.join_path(filepath)
if is_kodi_environment():
import xbmcvfs
with _lock_for_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
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()
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
@@ -202,32 +236,63 @@ class VFS:
if not os.path.isabs(filepath):
filepath = self.join_path(filepath)
if is_kodi_environment():
import xbmcvfs
with _lock_for_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)
# 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
# xbmcvfs has no atomic replace; the lock still keeps
# in-process readers from seeing a half-written file.
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
path = pathlib.Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
self._atomic_write(path, content.encode(encoding))
return True
except Exception as e:
logger.error(f"Error writing file {filepath}: {e}")
return False
@staticmethod
def _atomic_write(path, data: bytes) -> None:
"""
Write to a temp file in the same directory, fsync, then os.replace().
A reader (or a crash/restart mid-write) sees either the complete old
file or the complete new one, never a truncated session.json.
"""
fd, tmp_name = tempfile.mkstemp(
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
)
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
try:
# keep the original file's permissions if it already exists
if path.exists():
os.chmod(tmp_name, path.stat().st_mode & 0o7777)
except OSError:
pass
os.replace(tmp_name, path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
def write_binary(self, filepath: str, data: bytes) -> bool:
"""
Write binary content to file