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:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- 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:
- |
if [ -n "$CI_COMMIT_TAG" ]; then
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG \
--tag $CI_REGISTRY_IMAGE:latest \
--push .
else
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag $CI_REGISTRY_IMAGE:latest \
--push .
fi
VERSION=$(awk -F'"' '/"version":/ {print $4; exit}' wastream/config/settings.py)
echo "Extracted version $VERSION"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag $CI_REGISTRY_IMAGE:$VERSION \
--tag $CI_REGISTRY_IMAGE:latest \
--push .
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
- if: $CI_PIPELINE_SOURCE == "web"
when: manual

View file

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

View file

@ -22,6 +22,10 @@ RETRY_ERRORS = [
"REDIRECTOR_ERROR",
]
DEAD_LINK_ERRORS = [
"LINK_PASS_PROTECTED",
]
# ===========================
# AllDebrid Service Class
@ -154,6 +158,14 @@ class AllDebridService(BaseDebridService):
debrid_logger.debug(f"[AllDebrid] {error_code}")
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 == "LINK_HOST_UNAVAILABLE":
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}")
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 == "LINK_HOST_UNAVAILABLE":
hoster_name = hoster if hoster else urlparse(link).netloc.replace("www.", "")

View file

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

View file

@ -11,15 +11,18 @@ from wastream.utils.quality import quality_sort_key
# ===========================
# Premiumize Error Constants
# ===========================
RETRY_MESSAGES = [
"Fair use limit reached!",
"maximum of 25 active downloads"
RETRY_CODES = [
"service_down",
"rate_limit_reached",
"transient_error",
"link_generation_failed",
"semi_permanent_error",
"account_limit_reached",
"service_limit_reached",
]
LINK_DOWN_MESSAGES = [
"not found",
"not available",
"offline"
LINK_DOWN_CODES = [
"not_found",
]
@ -32,22 +35,21 @@ class PremiumizeService(BaseDebridService):
def _handle_api_error(
self,
error_code: str,
message: str,
attempt: int
) -> str:
message_lower = message.lower()
if any(kw in message_lower for kw in LINK_DOWN_MESSAGES):
debrid_logger.debug(f"[Premiumize] LINK_DOWN: {message}")
if error_code in LINK_DOWN_CODES:
debrid_logger.debug(f"[Premiumize] LINK_DOWN: {error_code} - {message}")
return "LINK_DOWN"
if any(kw in message for kw in RETRY_MESSAGES):
debrid_logger.error(f"[Premiumize] RETRY: {message}")
if error_code in RETRY_CODES:
debrid_logger.error(f"[Premiumize] RETRY: {error_code} - {message}")
if attempt >= settings.DEBRID_MAX_RETRIES - 1:
return "RETRY_ERROR"
return "RETRY"
debrid_logger.error(f"[Premiumize] Fatal: {message}")
debrid_logger.error(f"[Premiumize] Fatal: {error_code} - {message}")
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]:
@ -109,9 +111,10 @@ class PremiumizeService(BaseDebridService):
for attempt in range(settings.DEBRID_MAX_RETRIES):
try:
response = await http_client.get(
response = await http_client.post(
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
)
@ -149,9 +152,10 @@ class PremiumizeService(BaseDebridService):
data = response.json()
if data.get("status") != "success":
error_code = data.get("code", "")
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":
return "LINK_DOWN"
elif api_error_result == "FATAL_ERROR":

View file

@ -402,8 +402,6 @@ class TorBoxService(BaseDebridService):
for attempt in range(settings.DEBRID_MAX_RETRIES):
try:
data_payload = {"link": link}
if download_type == "webdl":
data_payload["add_only_if_cached"] = False
create_response = await http_client.post(
f"{self.API_URL}/{create_endpoint}",
@ -556,7 +554,13 @@ class TorBoxService(BaseDebridService):
debrid_logger.error("[TorBox] mylist failed")
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:
debrid_logger.error("[TorBox] mylist no files")
@ -592,7 +596,6 @@ class TorBoxService(BaseDebridService):
"token": api_key,
id_param_key: download_id,
"file_id": file_id,
"zip_link": False
},
headers=headers,
timeout=settings.HTTP_TIMEOUT

View file

@ -1,7 +1,9 @@
import asyncio
import re
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import uvicorn
from fastapi import FastAPI
@ -12,13 +14,17 @@ from starlette.requests import Request
from wastream.api.routes import router
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.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.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
# ===========================
@ -47,7 +53,32 @@ class SecurityHeadersMiddleware(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):
token = user_id_var.set(self._extract_user_id(request))
start_time = time.time()
response = None
try:
@ -65,6 +96,7 @@ class LoguruMiddleware(BaseHTTPMiddleware):
path_parts[2] = "***"
safe_path = "/".join(path_parts)
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;
}
.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 {
padding: 2px 8px;
border-radius: var(--radius-sm);
@ -4186,7 +4197,11 @@
if (filters.context && log.context !== filters.context) return false;
if (filters.level && log.level !== filters.level) 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;
}
@ -4222,11 +4237,16 @@
const entry = document.createElement('div');
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 = `
<div class="log-entry-header">
<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-context" style="background: ${log.context_color}20; color: ${log.context_color};">${log.context_icon} ${log.context}</span>
${userIdBadge}
</div>
<span class="log-message">${escapeHtml(log.message)}</span>
`;

View file

@ -1,3 +1,6 @@
import re
# ===========================
# Languages Dictionary
# ===========================
@ -163,6 +166,20 @@ def normalize_language(raw_language: str) -> str:
if 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)
if mapped:
return mapped

View file

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