mirror of
https://gitlab.com/10ho/wastream.git
synced 2026-08-16 04:08:56 +00:00
FIX: - WASource: series <-> movie conversion now works both ways (was movie -> series only) - Turbobit: canonicalize mirror domains (trbt.cc / turbobit.cc -> turbobit.net) ADD: - WASource: editable IMDB ID from the dashboard - Admin: export / import of the database data (backup) - Admin: all settings editable from the dashboard (values set in the .env stay read-only) - Admin: content cache browser (Cache tab) to view cached content / links - Config: resilient playback (fallback + racing), configurable in the user config - Config: export / import of the user config on the /configure page (credentials excluded) - Pastebin: multi-host support via known-domain detection (now also supports 1fichier links, etc.) - Pastebin: recursive automatic link discovery (index -> content)
166 lines
5.2 KiB
Python
166 lines
5.2 KiB
Python
import binascii
|
|
import json
|
|
from base64 import b64decode
|
|
from typing import Optional, Dict, List
|
|
|
|
from pydantic import BaseModel, field_validator, ConfigDict
|
|
|
|
from wastream.config.settings import settings
|
|
from wastream.utils.logger import api_logger
|
|
from wastream.utils.quality import AVAILABLE_RESOLUTIONS
|
|
|
|
|
|
# ===========================
|
|
# Pydantic Config Models
|
|
# ===========================
|
|
class DebridServiceEntry(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
service: str
|
|
api_key: str
|
|
hosts: List[str] = []
|
|
sources: List[str] = []
|
|
enable_nzb: bool = False
|
|
enable_full_season: bool = False
|
|
|
|
@field_validator("service")
|
|
@classmethod
|
|
def validate_service(cls, v):
|
|
if v not in settings.DEBRID_SERVICES:
|
|
raise ValueError(f"Invalid debrid service: {v}")
|
|
return v
|
|
|
|
@field_validator("api_key")
|
|
@classmethod
|
|
def validate_api_key(cls, v):
|
|
if not v:
|
|
raise ValueError("api_key must not be empty")
|
|
return v
|
|
|
|
|
|
class UserConfig(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
tmdb_api_token: str
|
|
debrid_services: List[DebridServiceEntry]
|
|
excluded_keywords: List[str] = []
|
|
languages: List[str] = []
|
|
resolutions: List[str] = AVAILABLE_RESOLUTIONS
|
|
max_results_per_resolution: int = 0
|
|
max_size_gb: float = 0.0
|
|
recheck_hoster_status: bool = False
|
|
|
|
@field_validator("tmdb_api_token")
|
|
@classmethod
|
|
def validate_tmdb_token(cls, v):
|
|
if not v:
|
|
raise ValueError("tmdb_api_token must not be empty")
|
|
return v
|
|
|
|
@field_validator("debrid_services")
|
|
@classmethod
|
|
def validate_debrid_services(cls, v):
|
|
if not v:
|
|
raise ValueError("At least one debrid service is required")
|
|
return v
|
|
|
|
@field_validator("max_results_per_resolution")
|
|
@classmethod
|
|
def validate_max_results(cls, v):
|
|
if v < 0:
|
|
raise ValueError("max_results_per_resolution must be >= 0")
|
|
return v
|
|
|
|
@field_validator("max_size_gb", mode="before")
|
|
@classmethod
|
|
def coerce_max_size(cls, v):
|
|
if isinstance(v, (int, float)):
|
|
if v < 0:
|
|
raise ValueError("max_size_gb must be >= 0")
|
|
return float(v)
|
|
return v
|
|
|
|
|
|
# ===========================
|
|
# Configuration Validation
|
|
# ===========================
|
|
def validate_config(config_base64: Optional[str]) -> Optional[Dict]:
|
|
if not config_base64:
|
|
api_logger.debug("Empty config provided")
|
|
return None
|
|
|
|
try:
|
|
api_logger.debug("Validating configuration")
|
|
decoded_bytes = b64decode(config_base64, validate=True)
|
|
decoded_str = decoded_bytes.decode('utf-8')
|
|
|
|
config_dict = json.loads(decoded_str)
|
|
|
|
if not isinstance(config_dict, dict):
|
|
api_logger.debug("Config is not a dict")
|
|
return None
|
|
|
|
# Legacy single-service format → convert to multi-service
|
|
if "debrid_services" not in config_dict:
|
|
if "debrid_service" in config_dict and "debrid_api_key" in config_dict:
|
|
if not config_dict["debrid_service"] or not config_dict["debrid_api_key"]:
|
|
api_logger.debug("Missing or empty debrid_service/debrid_api_key")
|
|
return None
|
|
|
|
config_dict["debrid_services"] = [{
|
|
"service": config_dict["debrid_service"],
|
|
"api_key": config_dict["debrid_api_key"]
|
|
}]
|
|
else:
|
|
api_logger.debug("Missing debrid configuration")
|
|
return None
|
|
|
|
validated = UserConfig.model_validate(config_dict)
|
|
result = validated.model_dump()
|
|
|
|
# Merge back any extra fields from the original config (sort_order, early_stop, nzbdav_url, etc.)
|
|
for key, value in config_dict.items():
|
|
if key not in result:
|
|
result[key] = value
|
|
|
|
api_logger.debug("Configuration validated successfully")
|
|
return result
|
|
|
|
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
api_logger.debug(f"Config validation failed: {type(e).__name__}")
|
|
return None
|
|
except Exception as e:
|
|
api_logger.debug(f"Config validation failed: {type(e).__name__}")
|
|
return None
|
|
|
|
|
|
# ===========================
|
|
# Media Info Extraction
|
|
# ===========================
|
|
def extract_media_info(content_id: str, content_type: str) -> Dict[str, Optional[str]]:
|
|
content_id_formatted = content_id.replace(".json", "")
|
|
|
|
if content_id_formatted.startswith("kitsu:"):
|
|
parts = content_id_formatted.split(":")
|
|
return {
|
|
"kitsu_id": parts[1] if len(parts) > 1 else "",
|
|
"episode": parts[2] if len(parts) > 2 else None,
|
|
"season": "1",
|
|
"imdb_id": None
|
|
}
|
|
|
|
if content_type == "series" and ":" in content_id_formatted:
|
|
parts = content_id_formatted.split(":")
|
|
return {
|
|
"imdb_id": parts[0],
|
|
"season": parts[1] if len(parts) > 1 else "1",
|
|
"episode": parts[2] if len(parts) > 2 else "1",
|
|
"kitsu_id": None
|
|
}
|
|
|
|
return {
|
|
"imdb_id": content_id_formatted,
|
|
"season": None,
|
|
"episode": None,
|
|
"kitsu_id": None
|
|
}
|