Compare commits

..

4 commits
v3.6.2 ... main

Author SHA1 Message Date
10ho
1bf15ab7dd v3.6.3: debrid APIs improvements and user tracking in logs
FIX:
- AllDebrid: LINK_PASS_PROTECTED treated as dead link (avoid useless retries)
- 1Fichier: strip ?af= as first URL parameter (regex instead of incomplete split)
- TorBox: mylist data defensive (handle list or dict response depending on API)
- Languages: normalize_language recognizes Scene release formats (MULTi.FR.LATIN, etc.)

ADD:
- Logs: ContextVar user_id auto-propagated to all coroutines
- Logs: HTTP middleware extracts user_uuid from all user-facing routes
- Logs: user_id badge visible in admin dashboard + extended search
- AllDebrid: dedicated WARNING log for NO_SERVER (VPN/datacenter blocked diagnostic)

REFACTOR:
- Premiumize: migration GET to POST + Authorization Bearer header (doc compliance)
- Premiumize: stable error codes instead of fragile text matching
- TorBox: removed useless add_only_if_cached in createwebdownload
- TorBox: removed zip_link=False in requestdl
2026-05-19 20:13:35 +02:00
10ho
e9a3da4513 Edit .gitlab-ci.yml 2026-04-05 13:44:33 +00:00
10ho
8d3c34ed59 Edit .gitlab-ci.yml 2026-04-05 13:41:52 +00:00
10ho
034ac7af24 Edit .gitlab-ci.yml 2026-04-05 13:40:09 +00:00
10 changed files with 155 additions and 46 deletions

View file

@ -12,21 +12,17 @@ build-docker:
before_script: before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker run --privileged --rm tonistiigi/binfmt --install all - docker run --privileged --rm tonistiigi/binfmt --install all
- docker buildx create --use --name multibuilder - docker context create builder-context
- docker buildx create --use --name multibuilder builder-context
script: script:
- | - |
if [ -n "$CI_COMMIT_TAG" ]; then VERSION=$(awk -F'"' '/"version":/ {print $4; exit}' wastream/config/settings.py)
docker buildx build \ echo "Extracted version $VERSION"
--platform linux/amd64,linux/arm64 \ docker buildx build \
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG \ --platform linux/amd64,linux/arm64 \
--tag $CI_REGISTRY_IMAGE:latest \ --tag $CI_REGISTRY_IMAGE:$VERSION \
--push . --tag $CI_REGISTRY_IMAGE:latest \
else --push .
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag $CI_REGISTRY_IMAGE:latest \
--push .
fi
rules: rules:
- if: $CI_COMMIT_BRANCH == "main" - if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_COMMIT_TAG when: manual

View file

@ -225,7 +225,7 @@ class Settings(BaseSettings):
return { return {
"id": self.ADDON_ID, "id": self.ADDON_ID,
"name": self.ADDON_NAME, "name": self.ADDON_NAME,
"version": "3.6.2", "version": "3.6.3",
"description": "Stremio addon to convert DDL to streams via debrid services", "description": "Stremio addon to convert DDL to streams via debrid services",
"catalogs": [], "catalogs": [],
"resources": ["stream"], "resources": ["stream"],

View file

@ -22,6 +22,10 @@ RETRY_ERRORS = [
"REDIRECTOR_ERROR", "REDIRECTOR_ERROR",
] ]
DEAD_LINK_ERRORS = [
"LINK_PASS_PROTECTED",
]
# =========================== # ===========================
# AllDebrid Service Class # AllDebrid Service Class
@ -154,6 +158,14 @@ class AllDebridService(BaseDebridService):
debrid_logger.debug(f"[AllDebrid] {error_code}") debrid_logger.debug(f"[AllDebrid] {error_code}")
return "LINK_DOWN" return "LINK_DOWN"
if error_code in DEAD_LINK_ERRORS:
debrid_logger.debug(f"[AllDebrid] {error_code} - link is password-protected (universally undebridable)")
return "LINK_DOWN"
if error_code == "NO_SERVER":
debrid_logger.warning(f"[AllDebrid] NO_SERVER - server blocked by AllDebrid (VPN/datacenter detected)")
return "FATAL_ERROR"
if error_code in RETRY_ERRORS: if error_code in RETRY_ERRORS:
if error_code == "LINK_HOST_UNAVAILABLE": if error_code == "LINK_HOST_UNAVAILABLE":
hoster_name = hoster if hoster else urlparse(link).netloc.replace("www.", "") hoster_name = hoster if hoster else urlparse(link).netloc.replace("www.", "")
@ -221,6 +233,14 @@ class AllDebridService(BaseDebridService):
debrid_logger.debug(f"[AllDebrid] {error_code2}") debrid_logger.debug(f"[AllDebrid] {error_code2}")
return "LINK_DOWN" return "LINK_DOWN"
if error_code2 in DEAD_LINK_ERRORS:
debrid_logger.debug(f"[AllDebrid] {error_code2} - link is password-protected (universally undebridable)")
return "LINK_DOWN"
if error_code2 == "NO_SERVER":
debrid_logger.warning(f"[AllDebrid] NO_SERVER - server blocked by AllDebrid (VPN/datacenter detected)")
return "FATAL_ERROR"
if error_code2 in RETRY_ERRORS: if error_code2 in RETRY_ERRORS:
if error_code2 == "LINK_HOST_UNAVAILABLE": if error_code2 == "LINK_HOST_UNAVAILABLE":
hoster_name = hoster if hoster else urlparse(link).netloc.replace("www.", "") hoster_name = hoster if hoster else urlparse(link).netloc.replace("www.", "")

View file

@ -1,3 +1,4 @@
import re
import time import time
from asyncio import sleep from asyncio import sleep
from typing import Optional, List, Dict from typing import Optional, List, Dict
@ -69,9 +70,8 @@ class OneFichierService(BaseDebridService):
debrid_logger.error("[1Fichier] Empty API key") debrid_logger.error("[1Fichier] Empty API key")
return "FATAL_ERROR" return "FATAL_ERROR"
cleaned_link = link cleaned_link = re.sub(r"[?&]af=[^&]*", "", link)
if "&af=" in cleaned_link: cleaned_link = cleaned_link.replace("?&", "?").rstrip("?&")
cleaned_link = cleaned_link.split("&af=")[0]
debrid_logger.debug("[1Fichier] Converting link") debrid_logger.debug("[1Fichier] Converting link")

View file

@ -11,15 +11,18 @@ from wastream.utils.quality import quality_sort_key
# =========================== # ===========================
# Premiumize Error Constants # Premiumize Error Constants
# =========================== # ===========================
RETRY_MESSAGES = [ RETRY_CODES = [
"Fair use limit reached!", "service_down",
"maximum of 25 active downloads" "rate_limit_reached",
"transient_error",
"link_generation_failed",
"semi_permanent_error",
"account_limit_reached",
"service_limit_reached",
] ]
LINK_DOWN_MESSAGES = [ LINK_DOWN_CODES = [
"not found", "not_found",
"not available",
"offline"
] ]
@ -32,22 +35,21 @@ class PremiumizeService(BaseDebridService):
def _handle_api_error( def _handle_api_error(
self, self,
error_code: str,
message: str, message: str,
attempt: int attempt: int
) -> str: ) -> str:
message_lower = message.lower() if error_code in LINK_DOWN_CODES:
debrid_logger.debug(f"[Premiumize] LINK_DOWN: {error_code} - {message}")
if any(kw in message_lower for kw in LINK_DOWN_MESSAGES):
debrid_logger.debug(f"[Premiumize] LINK_DOWN: {message}")
return "LINK_DOWN" return "LINK_DOWN"
if any(kw in message for kw in RETRY_MESSAGES): if error_code in RETRY_CODES:
debrid_logger.error(f"[Premiumize] RETRY: {message}") debrid_logger.error(f"[Premiumize] RETRY: {error_code} - {message}")
if attempt >= settings.DEBRID_MAX_RETRIES - 1: if attempt >= settings.DEBRID_MAX_RETRIES - 1:
return "RETRY_ERROR" return "RETRY_ERROR"
return "RETRY" return "RETRY"
debrid_logger.error(f"[Premiumize] Fatal: {message}") debrid_logger.error(f"[Premiumize] Fatal: {error_code} - {message}")
return "FATAL_ERROR" return "FATAL_ERROR"
async def check_cache_and_enrich(self, results: List[Dict], api_key: str, config: Dict, timeout_remaining: float, user_season: Optional[str] = None, user_episode: Optional[str] = None, user_hosts: Optional[List[str]] = None) -> List[Dict]: async def check_cache_and_enrich(self, results: List[Dict], api_key: str, config: Dict, timeout_remaining: float, user_season: Optional[str] = None, user_episode: Optional[str] = None, user_hosts: Optional[List[str]] = None) -> List[Dict]:
@ -109,9 +111,10 @@ class PremiumizeService(BaseDebridService):
for attempt in range(settings.DEBRID_MAX_RETRIES): for attempt in range(settings.DEBRID_MAX_RETRIES):
try: try:
response = await http_client.get( response = await http_client.post(
f"{settings.PREMIUMIZE_API_URL}/transfer/directdl", f"{settings.PREMIUMIZE_API_URL}/transfer/directdl",
params={"apikey": api_key, "src": link}, headers={"Authorization": f"Bearer {api_key}"},
data={"src": link},
timeout=settings.HTTP_TIMEOUT timeout=settings.HTTP_TIMEOUT
) )
@ -149,9 +152,10 @@ class PremiumizeService(BaseDebridService):
data = response.json() data = response.json()
if data.get("status") != "success": if data.get("status") != "success":
error_code = data.get("code", "")
message = data.get("message", "Unknown error") message = data.get("message", "Unknown error")
api_error_result = self._handle_api_error(message, attempt) api_error_result = self._handle_api_error(error_code, message, attempt)
if api_error_result == "LINK_DOWN": if api_error_result == "LINK_DOWN":
return "LINK_DOWN" return "LINK_DOWN"
elif api_error_result == "FATAL_ERROR": elif api_error_result == "FATAL_ERROR":

View file

@ -402,8 +402,6 @@ class TorBoxService(BaseDebridService):
for attempt in range(settings.DEBRID_MAX_RETRIES): for attempt in range(settings.DEBRID_MAX_RETRIES):
try: try:
data_payload = {"link": link} data_payload = {"link": link}
if download_type == "webdl":
data_payload["add_only_if_cached"] = False
create_response = await http_client.post( create_response = await http_client.post(
f"{self.API_URL}/{create_endpoint}", f"{self.API_URL}/{create_endpoint}",
@ -556,7 +554,13 @@ class TorBoxService(BaseDebridService):
debrid_logger.error("[TorBox] mylist failed") debrid_logger.error("[TorBox] mylist failed")
return "FATAL_ERROR" return "FATAL_ERROR"
files = mylist_data.get("data", {}).get("files", []) mylist_payload = mylist_data.get("data", {})
if isinstance(mylist_payload, list):
mylist_item = mylist_payload[0] if mylist_payload else {}
else:
mylist_item = mylist_payload if isinstance(mylist_payload, dict) else {}
files = mylist_item.get("files", [])
if not files: if not files:
debrid_logger.error("[TorBox] mylist no files") debrid_logger.error("[TorBox] mylist no files")
@ -592,7 +596,6 @@ class TorBoxService(BaseDebridService):
"token": api_key, "token": api_key,
id_param_key: download_id, id_param_key: download_id,
"file_id": file_id, "file_id": file_id,
"zip_link": False
}, },
headers=headers, headers=headers,
timeout=settings.HTTP_TIMEOUT timeout=settings.HTTP_TIMEOUT

View file

@ -1,7 +1,9 @@
import asyncio import asyncio
import re
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Optional
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
@ -12,13 +14,17 @@ from starlette.requests import Request
from wastream.api.routes import router from wastream.api.routes import router
from wastream.utils.database import setup_database, teardown_database, cleanup_expired_data, rebuild_cache_stats from wastream.utils.database import setup_database, teardown_database, cleanup_expired_data, rebuild_cache_stats
from wastream.utils.helpers import decode_playback_token
from wastream.utils.http_client import http_client from wastream.utils.http_client import http_client
from wastream.config.settings import settings from wastream.config.settings import settings
from wastream.utils.logger import setup_logger, addon_logger, api_logger from wastream.utils.logger import setup_logger, addon_logger, api_logger, user_id_var
from wastream.services.health import start_background_health_check from wastream.services.health import start_background_health_check
from wastream.services.pastebin_scraper import start_pastebin_scraper_loop from wastream.services.pastebin_scraper import start_pastebin_scraper_loop
UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
# =========================== # ===========================
# Logger Setup # Logger Setup
# =========================== # ===========================
@ -47,7 +53,32 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
class LoguruMiddleware(BaseHTTPMiddleware): class LoguruMiddleware(BaseHTTPMiddleware):
def _extract_user_id(self, request: Request) -> Optional[str]:
path = request.url.path
parts = path.split("/")
if len(parts) >= 2 and UUID_PATTERN.match(parts[1]):
return parts[1]
if len(parts) >= 3 and parts[1] == "user" and UUID_PATTERN.match(parts[2]):
return parts[2]
if len(parts) >= 3 and parts[1] == "playback":
data = decode_playback_token(parts[2])
if data:
user_uuid = data.get("u")
if user_uuid and UUID_PATTERN.match(user_uuid):
return user_uuid
if path.startswith("/resolve"):
user_uuid = request.query_params.get("user_uuid")
if user_uuid and UUID_PATTERN.match(user_uuid):
return user_uuid
return None
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
token = user_id_var.set(self._extract_user_id(request))
start_time = time.time() start_time = time.time()
response = None response = None
try: try:
@ -65,6 +96,7 @@ class LoguruMiddleware(BaseHTTPMiddleware):
path_parts[2] = "***" path_parts[2] = "***"
safe_path = "/".join(path_parts) safe_path = "/".join(path_parts)
api_logger.debug(f"{request.method} {safe_path} - {response.status_code if response else '500'} - {process_time:.2f}s") api_logger.debug(f"{request.method} {safe_path} - {response.status_code if response else '500'} - {process_time:.2f}s")
user_id_var.reset(token)
# =========================== # ===========================

View file

@ -1818,6 +1818,17 @@
min-width: 70px; min-width: 70px;
} }
.log-user-id {
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
background: rgba(250, 204, 21, 0.15);
color: #facc15;
font-family: var(--font-mono, monospace);
}
.log-context { .log-context {
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@ -4186,7 +4197,11 @@
if (filters.context && log.context !== filters.context) return false; if (filters.context && log.context !== filters.context) return false;
if (filters.level && log.level !== filters.level) return false; if (filters.level && log.level !== filters.level) return false;
if (filters.prefix && !log.message.includes(`[${filters.prefix}]`)) return false; if (filters.prefix && !log.message.includes(`[${filters.prefix}]`)) return false;
if (filters.search && !log.message.toLowerCase().includes(filters.search)) return false; if (filters.search) {
const inMessage = log.message.toLowerCase().includes(filters.search);
const inUserId = log.user_id && log.user_id.toLowerCase().includes(filters.search);
if (!inMessage && !inUserId) return false;
}
return true; return true;
} }
@ -4222,11 +4237,16 @@
const entry = document.createElement('div'); const entry = document.createElement('div');
entry.className = 'log-entry'; entry.className = 'log-entry';
const userIdBadge = log.user_id
? `<span class="log-user-id" title="${escapeHtml(log.user_id)}">👤 ${escapeHtml(log.user_id.substring(0, 8))}</span>`
: '';
entry.innerHTML = ` entry.innerHTML = `
<div class="log-entry-header"> <div class="log-entry-header">
<span class="log-time">${escapeHtml(log.time)}</span> <span class="log-time">${escapeHtml(log.time)}</span>
<span class="log-level" style="color: ${log.level_color};">${log.level_icon} ${log.level}</span> <span class="log-level" style="color: ${log.level_color};">${log.level_icon} ${log.level}</span>
<span class="log-context" style="background: ${log.context_color}20; color: ${log.context_color};">${log.context_icon} ${log.context}</span> <span class="log-context" style="background: ${log.context_color}20; color: ${log.context_color};">${log.context_icon} ${log.context}</span>
${userIdBadge}
</div> </div>
<span class="log-message">${escapeHtml(log.message)}</span> <span class="log-message">${escapeHtml(log.message)}</span>
`; `;

View file

@ -1,3 +1,6 @@
import re
# =========================== # ===========================
# Languages Dictionary # Languages Dictionary
# =========================== # ===========================
@ -163,6 +166,20 @@ def normalize_language(raw_language: str) -> str:
if mapped_inner: if mapped_inner:
return mapped_inner return mapped_inner
if normalized.startswith("multi") and any(sep in normalized for sep in [".", "-", "_", " "]):
parts = re.split(r"[.\-_ ]", normalized)
mapped_langs = []
for part in parts:
if part in ("multi", ""):
continue
mapped = LANGUAGE_MAPPING.get(part)
if mapped and mapped not in mapped_langs:
mapped_langs.append(mapped)
if len(mapped_langs) > 1:
return f"Multi ({', '.join(mapped_langs)})"
if len(mapped_langs) == 1:
return mapped_langs[0]
mapped = LANGUAGE_MAPPING.get(normalized) mapped = LANGUAGE_MAPPING.get(normalized)
if mapped: if mapped:
return mapped return mapped

View file

@ -2,6 +2,7 @@ import re
import sys import sys
import logging import logging
from collections import deque from collections import deque
from contextvars import ContextVar
from threading import Lock from threading import Lock
from typing import List, Dict, Optional from typing import List, Dict, Optional
from loguru import logger from loguru import logger
@ -9,6 +10,8 @@ from loguru import logger
PREFIX_PATTERN = re.compile(r'^\[([^\]]+)\]') PREFIX_PATTERN = re.compile(r'^\[([^\]]+)\]')
user_id_var: ContextVar[Optional[str]] = ContextVar("user_id", default=None)
LOG_LEVEL = "INFO" LOG_LEVEL = "INFO"
MAX_LOG_BUFFER = 5000 MAX_LOG_BUFFER = 5000
@ -49,7 +52,7 @@ class LogBuffer:
with self._lock: with self._lock:
self._buffer.append(log_entry) self._buffer.append(log_entry)
def get_logs(self, since: float = 0, context: Optional[str] = None, level: Optional[str] = None, prefix: Optional[str] = None, search: Optional[str] = None) -> List[Dict]: def get_logs(self, since: float = 0, context: Optional[str] = None, level: Optional[str] = None, prefix: Optional[str] = None, search: Optional[str] = None, user_id: Optional[str] = None) -> List[Dict]:
with self._lock: with self._lock:
logs = list(self._buffer) logs = list(self._buffer)
@ -70,9 +73,17 @@ class LogBuffer:
else: else:
logs = [log for log in logs if log["message"].startswith(f"[{prefix}]")] logs = [log for log in logs if log["message"].startswith(f"[{prefix}]")]
if user_id:
user_id_lower = user_id.lower()
logs = [log for log in logs if log.get("user_id") and user_id_lower in log["user_id"].lower()]
if search: if search:
search_lower = search.lower() search_lower = search.lower()
logs = [log for log in logs if search_lower in log["message"].lower()] logs = [
log for log in logs
if search_lower in log["message"].lower()
or (log.get("user_id") and search_lower in log["user_id"].lower())
]
return logs return logs
@ -101,6 +112,7 @@ def log_sink(message):
context = record["extra"].get("context", "ADDON") context = record["extra"].get("context", "ADDON")
context_data = CONTEXTS.get(context, {"icon": "📦", "hex": "#71717a"}) context_data = CONTEXTS.get(context, {"icon": "📦", "hex": "#71717a"})
level_name = record["level"].name level_name = record["level"].name
user_id = user_id_var.get()
log_entry = { log_entry = {
"timestamp": record["time"].timestamp(), "timestamp": record["time"].timestamp(),
@ -112,6 +124,7 @@ def log_sink(message):
"context_icon": context_data["icon"], "context_icon": context_data["icon"],
"context_color": context_data["hex"], "context_color": context_data["hex"],
"message": record["message"], "message": record["message"],
"user_id": user_id,
} }
log_buffer.add(log_entry) log_buffer.add(log_entry)
@ -124,11 +137,15 @@ def format_log(record):
context_icon = context_data["icon"] context_icon = context_data["icon"]
level_icon = LEVEL_ICONS.get(record["level"].name, "") level_icon = LEVEL_ICONS.get(record["level"].name, "")
user_id = user_id_var.get()
user_segment = f"<yellow>👤 {user_id[:8]}</yellow> | " if user_id else ""
return ( return (
"<white>{time:YYYY-MM-DD}</white> " "<white>{time:YYYY-MM-DD}</white> "
"<magenta>{time:HH:mm:ss}</magenta> | " "<magenta>{time:HH:mm:ss}</magenta> | "
f"<level>{level_icon} {{level: <8}}</level> | " f"<level>{level_icon} {{level: <8}}</level> | "
f"<{context_color}>{context_icon} {{extra[context]: <10}}</{context_color}> | " f"<{context_color}>{context_icon} {{extra[context]: <10}}</{context_color}> | "
f"{user_segment}"
"<level>{message}</level>\n" "<level>{message}</level>\n"
) )
@ -159,8 +176,8 @@ def get_logger(context: str):
return logger.bind(context=context) return logger.bind(context=context)
def get_logs(since: float = 0, context: Optional[str] = None, level: Optional[str] = None, prefix: Optional[str] = None, search: Optional[str] = None) -> List[Dict]: def get_logs(since: float = 0, context: Optional[str] = None, level: Optional[str] = None, prefix: Optional[str] = None, search: Optional[str] = None, user_id: Optional[str] = None) -> List[Dict]:
return log_buffer.get_logs(since, context, level, prefix, search) return log_buffer.get_logs(since, context, level, prefix, search, user_id)
def clear_logs(): def clear_logs():