Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf15ab7dd | ||
|
|
e9a3da4513 | ||
|
|
8d3c34ed59 | ||
|
|
034ac7af24 |
10 changed files with 155 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.", "")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ===========================
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue