mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-25 16:22:33 +01:00
update for non-blocking
This commit is contained in:
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user