From 485fcd90dcf32eeb8dd49dbb40091f3aa7783bf7 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 14 May 2026 19:35:16 +0200 Subject: [PATCH 1/2] feat(torbox): implement payload slicing and auto-cacher toggle --- wastream/debrid/torbox.py | 73 +++++++++++++++++++++++++------- wastream/public/translations.js | 4 ++ wastream/services/stream.py | 34 ++++++++++++++- wastream/templates/index.html | 74 ++++++++++++++++++++++++++++++++- wastream/utils/validators.py | 1 + 5 files changed, 170 insertions(+), 16 deletions(-) diff --git a/wastream/debrid/torbox.py b/wastream/debrid/torbox.py index 56dd211..dd07ed2 100644 --- a/wastream/debrid/torbox.py +++ b/wastream/debrid/torbox.py @@ -169,15 +169,19 @@ class TorBoxService(BaseDebridService): if not links or not api_key: return links + links.sort(key=quality_sort_key) + top_links = links[:15] + rest_links = links[15:] + cache_timeout = config.get("stream_request_timeout", settings.STREAM_REQUEST_TIMEOUT) check_endpoint = "usenet/checkcached" if download_type == "usenet" else "webdl/checkcached" - cache_logger.debug(f"[TorBox] Checking {len(links)} links (timeout: {cache_timeout}s)") + cache_logger.debug(f"[TorBox] Checking top {len(top_links)} links (timeout: {cache_timeout}s)") start_time = time.time() link_to_hash = {} hashes = [] - for link_dict in links: + for link_dict in top_links: link = link_dict.get("link") if link: if download_type == "usenet": @@ -193,8 +197,10 @@ class TorBoxService(BaseDebridService): hashes.append(link_hash) if not hashes: - cache_logger.debug("[TorBox] No valid links") - return links + cache_logger.debug("[TorBox] No valid links to hash") + for link_dict in rest_links: + link_dict["cache_status"] = "uncached" + return top_links + rest_links http_error_count = 0 @@ -216,25 +222,29 @@ class TorBoxService(BaseDebridService): if response.status_code != 200: cache_logger.error(f"[TorBox] Batch failed: HTTP {response.status_code}") - for link_dict in links: + for link_dict in top_links: link_dict["cache_status"] = "uncached" - return links + for link_dict in rest_links: + link_dict["cache_status"] = "uncached" + return top_links + rest_links response_json = response.json() error_code = response_json.get("error") cooldown_result, http_error_count = await self._handle_cooldown_limit(error_code, http_error_count) if cooldown_result == "RETRY_ERROR": - for link_dict in links: + for link_dict in top_links: link_dict["cache_status"] = "uncached" - return links + for link_dict in rest_links: + link_dict["cache_status"] = "uncached" + return top_links + rest_links elif cooldown_result == "RETRY": continue http_error_count = 0 cache_data = response_json.get("data", {}) - for link_dict in links: + for link_dict in top_links: link = link_dict.get("link") link_hash = link_to_hash.get(link) @@ -280,15 +290,18 @@ class TorBoxService(BaseDebridService): except Exception as e: cache_logger.error(f"[TorBox] Batch error: {type(e).__name__}: {e}") - for link_dict in links: + for link_dict in top_links: link_dict["cache_status"] = "uncached" break - cached_count = sum(1 for r in links if r.get("cache_status") == "cached") - elapsed = time.time() - start_time - cache_logger.debug(f"[TorBox] Done in {elapsed:.1f}s: {cached_count} cached / {len(links) - cached_count} uncached") + for link_dict in rest_links: + link_dict["cache_status"] = "uncached" - return links + cached_count = sum(1 for r in top_links if r.get("cache_status") == "cached") + elapsed = time.time() - start_time + cache_logger.debug(f"[TorBox] Done in {elapsed:.1f}s: {cached_count} cached / {len(top_links) - cached_count} uncached (skipped {len(rest_links)})") + + return top_links + rest_links 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]: start_time = time.time() @@ -484,6 +497,38 @@ class TorBoxService(BaseDebridService): debrid_logger.error(f"[TorBox] Failed after {settings.DEBRID_MAX_RETRIES} attempts") return "FATAL_ERROR", http_error_count + async def trigger_auto_download(self, link: str, api_key: str): + if not api_key or not link: + return + + download_type = "usenet" if "/nzb/" in link else "webdl" + create_endpoint = "usenet/createusenetdownload" if download_type == "usenet" else "webdl/createwebdownload" + + cleaned_link = link + if download_type == "webdl" and "&af=" in cleaned_link: + cleaned_link = cleaned_link.split("&af=")[0] + elif download_type == "usenet": + nzb_id = link.split("/")[-1] + cleaned_link = f"{settings.DARKI_API_URL}/nzb/{nzb_id}/download" + + try: + headers = self._get_headers(api_key) + data_payload = {"link": cleaned_link} + if download_type == "webdl": + data_payload["add_only_if_cached"] = False + + debrid_logger.info(f"[TorBox] Triggering auto-cacher for uncached link: {cleaned_link[:50]}...") + + # Fire and forget without retries for background job + await http_client.post( + f"{self.API_URL}/{create_endpoint}", + data=data_payload, + headers=headers, + timeout=settings.HTTP_TIMEOUT + ) + except Exception as e: + debrid_logger.error(f"[TorBox] Auto-cacher background trigger failed: {type(e).__name__}: {e}") + async def convert_link(self, link: str, api_key: str, season: Optional[str] = None, episode: Optional[str] = None, hoster: Optional[str] = None) -> Optional[str]: if not api_key: debrid_logger.error("[TorBox] Empty API key") diff --git a/wastream/public/translations.js b/wastream/public/translations.js index d39fec4..84a9ab9 100644 --- a/wastream/public/translations.js +++ b/wastream/public/translations.js @@ -47,6 +47,7 @@ const translations = { cachedOnly: 'Cached links only', enableUsenet: 'Enable Usenet (TorBox only)', fullSeasonPacks: 'Include Full Season Packs (Usenet)', + autoCache: 'Auto-cache best link (TorBox)', deduplicateResults: 'Deduplicate results', earlyStop: 'Early Stop', earlyStopIncludeNzb: 'Include NZB in count', @@ -62,6 +63,7 @@ const translations = { tokenLabel: 'Token:', hostsInfo: 'Select specific hosts or leave empty for all', sourcesInfo: 'Select specific sources or leave empty for all', + autoCacheInfo: 'Automatically triggers a background download for the best quality link if no streams are cached.', timeoutInfo: 'Maximum search time (default: 20s)', excludedInfo: 'Streams with these words will be filtered', deduplicateInfo: 'Hides duplicate results from different hosts', @@ -179,6 +181,7 @@ const translations = { cachedOnly: 'Liens en cache uniquement', enableUsenet: 'Activer Usenet (TorBox uniquement)', fullSeasonPacks: 'Inclure les packs de saison (Usenet)', + autoCache: 'Auto-cache le meilleur lien (TorBox)', deduplicateResults: 'Dédupliquer les résultats', earlyStop: 'Arrêt Anticipé', earlyStopIncludeNzb: 'Inclure NZB dans le comptage', @@ -194,6 +197,7 @@ const translations = { tokenLabel: 'Token :', hostsInfo: 'Sélectionner des hébergeurs ou laisser vide pour tous', sourcesInfo: 'Sélectionner des sources ou laisser vide pour toutes', + autoCacheInfo: 'Déclenche automatiquement le téléchargement en arrière-plan du meilleur lien si aucun stream n\'est en cache.', timeoutInfo: 'Temps de recherche maximum (défaut : 20s)', excludedInfo: 'Les streams avec ces mots seront filtrés', deduplicateInfo: 'Masque les résultats identiques provenant de différents hébergeurs', diff --git a/wastream/services/stream.py b/wastream/services/stream.py index 0bc1c8e..94e5fcd 100644 --- a/wastream/services/stream.py +++ b/wastream/services/stream.py @@ -475,6 +475,22 @@ class StreamService: stream_logger.debug(f"Excluded {excluded_count} streams") streams = filtered_streams + torbox_config = next((s for s in get_debrid_services(config) if s.get("service") == "torbox"), None) + if torbox_config and torbox_config.get("auto_cache", False): + cached_count = sum(1 for s in streams if "⚡" in s["name"] and "TB" in s["name"]) + + if cached_count == 0 and len(streams) > 0: + best_torbox_stream = next((s for s in streams if "TB" in s["name"]), None) + if best_torbox_stream: + token_data = decode_playback_token(best_torbox_stream["url"].split("/")[-2]) + link_to_download = token_data.get("l") + + if link_to_download: + asyncio.create_task(torbox_service.trigger_auto_download( + link=link_to_download, + api_key=torbox_config.get("api_key") + )) + return streams async def _get_metadata(self, imdb_id: str, tmdb_api_token: str) -> Optional[Dict]: @@ -1286,7 +1302,23 @@ class StreamService: excluded_count = len(streams) - len(filtered_streams) if excluded_count > 0: stream_logger.debug(f"Excluded {excluded_count} streams") - return filtered_streams + streams = filtered_streams + + torbox_config = next((s for s in get_debrid_services(config) if s.get("service") == "torbox"), None) + if torbox_config and torbox_config.get("auto_cache", False): + cached_count = sum(1 for s in streams if "⚡" in s["name"] and "TB" in s["name"]) + + if cached_count == 0 and len(streams) > 0: + best_torbox_stream = next((s for s in streams if "TB" in s["name"]), None) + if best_torbox_stream: + token_data = decode_playback_token(best_torbox_stream["url"].split("/")[-2]) + link_to_download = token_data.get("l") + + if link_to_download: + asyncio.create_task(torbox_service.trigger_auto_download( + link=link_to_download, + api_key=torbox_config.get("api_key") + )) return streams diff --git a/wastream/templates/index.html b/wastream/templates/index.html index 2e53a0b..584a3cc 100644 --- a/wastream/templates/index.html +++ b/wastream/templates/index.html @@ -1768,6 +1768,22 @@ +
@@ -2322,6 +2338,7 @@ const currentWebdavPassInput = entry.querySelector('.nzbdavWebdavPassInput'); const currentNzbToggle = entry.querySelector('.service-nzb-toggle-btn'); const currentFullseasonToggle = entry.querySelector('.service-fullseason-toggle-btn'); + const currentAutocacheToggle = entry.querySelector('.service-autocache-toggle-btn'); const targetSelect = targetEntry.querySelector('.debridServiceSelect'); const targetInput = targetEntry.querySelector('.debridKeyInput'); @@ -2332,6 +2349,7 @@ const targetWebdavPassInput = targetEntry.querySelector('.nzbdavWebdavPassInput'); const targetNzbToggle = targetEntry.querySelector('.service-nzb-toggle-btn'); const targetFullseasonToggle = targetEntry.querySelector('.service-fullseason-toggle-btn'); + const targetAutocacheToggle = targetEntry.querySelector('.service-autocache-toggle-btn'); const currentService = currentSelect.value; const currentApiKey = currentInput.value; @@ -2342,6 +2360,7 @@ const currentWebdavPass = currentWebdavPassInput ? currentWebdavPassInput.value : ''; const currentNzbEnabled = currentNzbToggle ? currentNzbToggle.classList.contains('active') : false; const currentFullseasonEnabled = currentFullseasonToggle ? currentFullseasonToggle.classList.contains('active') : false; + const currentAutocacheEnabled = currentAutocacheToggle ? currentAutocacheToggle.classList.contains('active') : false; const targetService = targetSelect.value; const targetApiKey = targetInput.value; @@ -2352,6 +2371,7 @@ const targetWebdavPass = targetWebdavPassInput ? targetWebdavPassInput.value : ''; const targetNzbEnabled = targetNzbToggle ? targetNzbToggle.classList.contains('active') : false; const targetFullseasonEnabled = targetFullseasonToggle ? targetFullseasonToggle.classList.contains('active') : false; + const targetAutocacheEnabled = targetAutocacheToggle ? targetAutocacheToggle.classList.contains('active') : false; currentSelect.value = targetService; currentInput.value = targetApiKey; @@ -2387,6 +2407,17 @@ if (targetFullseasonCheckbox) targetFullseasonCheckbox.checked = currentFullseasonEnabled; } + if (currentAutocacheToggle) { + currentAutocacheToggle.classList.toggle('active', targetAutocacheEnabled); + const currentAutocacheCheckbox = entry.querySelector('.service-enable-autocache'); + if (currentAutocacheCheckbox) currentAutocacheCheckbox.checked = targetAutocacheEnabled; + } + if (targetAutocacheToggle) { + targetAutocacheToggle.classList.toggle('active', currentAutocacheEnabled); + const targetAutocacheCheckbox = targetEntry.querySelector('.service-enable-autocache'); + if (targetAutocacheCheckbox) targetAutocacheCheckbox.checked = currentAutocacheEnabled; + } + updateDebridLink(entry, targetService); updateDebridLink(targetEntry, currentService); @@ -2397,8 +2428,10 @@ const currentUsenetField = entry.querySelector('.service-usenet-toggle'); const currentFullseasonField = entry.querySelector('.service-fullseason-toggle'); + const currentAutocacheField = entry.querySelector('.service-autocache-toggle'); const targetUsenetField = targetEntry.querySelector('.service-usenet-toggle'); const targetFullseasonField = targetEntry.querySelector('.service-fullseason-toggle'); + const targetAutocacheField = targetEntry.querySelector('.service-autocache-toggle'); if (currentUsenetField) { currentUsenetField.style.display = targetService === 'torbox' ? 'block' : 'none'; @@ -2413,6 +2446,9 @@ currentFullseasonField.style.opacity = ''; } } + if (currentAutocacheField) { + currentAutocacheField.style.display = targetService === 'torbox' ? 'block' : 'none'; + } if (targetUsenetField) { targetUsenetField.style.display = currentService === 'torbox' ? 'block' : 'none'; } @@ -2426,6 +2462,9 @@ targetFullseasonField.style.opacity = ''; } } + if (targetAutocacheField) { + targetAutocacheField.style.display = currentService === 'torbox' ? 'block' : 'none'; + } updateServiceNumbers(); checkWarningIndicator(); @@ -2780,7 +2819,7 @@ } }; - const addDebridServiceEntry = (service = 'alldebrid', apiKey = '', hosts = [], sources = [], nzbdavUrl = '', enableNzb = false, enableFullSeason = false, webdavUser = '', webdavPassword = '') => { + const addDebridServiceEntry = (service = 'alldebrid', apiKey = '', hosts = [], sources = [], nzbdavUrl = '', enableNzb = false, enableFullSeason = false, autoCache = false, webdavUser = '', webdavPassword = '') => { const index = getDebridServiceCount(); const trans = translations[state.currentLanguage]; const label = trans.labels?.debridService || 'Debrid service'; @@ -2866,6 +2905,22 @@
+
+
+
+ + + + + + + +
+ ${trans.toggles?.autoCache || 'Auto-cache best link (TorBox)'} +
+ + ${trans.help?.autoCacheInfo || 'Automatically triggers a background download for the best quality link if no streams are cached.'} +
`; els.debridServicesContainer.appendChild(entry); @@ -2878,10 +2933,13 @@ const sourcesSelect = entry.querySelector('.sourcesSelect'); const usenetToggleField = entry.querySelector('.service-usenet-toggle'); const fullseasonToggleField = entry.querySelector('.service-fullseason-toggle'); + const autocacheToggleField = entry.querySelector('.service-autocache-toggle'); const nzbToggleBtn = entry.querySelector('.service-nzb-toggle-btn'); const fullseasonToggleBtn = entry.querySelector('.service-fullseason-toggle-btn'); + const autocacheToggleBtn = entry.querySelector('.service-autocache-toggle-btn'); const enableNzbCheckbox = entry.querySelector('.service-enable-nzb'); const enableFullseasonCheckbox = entry.querySelector('.service-enable-fullseason'); + const enableAutocacheCheckbox = entry.querySelector('.service-enable-autocache'); select.addEventListener('sl-change', (e) => { updateDebridLink(entry, e.target.value); @@ -2890,6 +2948,7 @@ if (selectedService === 'torbox') { usenetToggleField.style.display = 'block'; fullseasonToggleField.style.display = 'block'; + autocacheToggleField.style.display = 'block'; if (enableNzbCheckbox.checked) { fullseasonToggleField.style.pointerEvents = 'auto'; fullseasonToggleField.style.opacity = '1'; @@ -2899,12 +2958,14 @@ } } else if (selectedService === 'nzbdav') { usenetToggleField.style.display = 'none'; + autocacheToggleField.style.display = 'none'; fullseasonToggleField.style.display = 'block'; fullseasonToggleField.style.pointerEvents = 'auto'; fullseasonToggleField.style.opacity = '1'; } else { usenetToggleField.style.display = 'none'; fullseasonToggleField.style.display = 'none'; + autocacheToggleField.style.display = 'none'; } checkWarningIndicator(); @@ -2938,6 +2999,13 @@ }); } + if (autocacheToggleField) { + autocacheToggleField.addEventListener('click', () => { + enableAutocacheCheckbox.checked = !enableAutocacheCheckbox.checked; + autocacheToggleBtn.classList.toggle('active', enableAutocacheCheckbox.checked); + }); + } + removeBtn.addEventListener('click', () => { entry.remove(); updateServiceNumbers(); @@ -2988,6 +3056,7 @@ const nzbdavWebdavPassInput = entry.querySelector('.nzbdavWebdavPassInput'); const enableNzbCheckbox = entry.querySelector('.service-enable-nzb'); const enableFullseasonCheckbox = entry.querySelector('.service-enable-fullseason'); + const enableAutocacheCheckbox = entry.querySelector('.service-enable-autocache'); if (select && input) { const service = select.value; @@ -3016,6 +3085,7 @@ if (service === 'torbox') { serviceEntry.enable_nzb = enableNzbCheckbox ? enableNzbCheckbox.checked : false; serviceEntry.enable_full_season = enableFullseasonCheckbox ? enableFullseasonCheckbox.checked : false; + serviceEntry.auto_cache = enableAutocacheCheckbox ? enableAutocacheCheckbox.checked : false; } if (service === 'nzbdav') { @@ -3781,6 +3851,7 @@ const webdavPasswordValue = serviceEntry.service === 'nzbdav' ? (config.webdav_password || '') : ''; const enableNzb = serviceEntry.enable_nzb || false; const enableFullSeason = serviceEntry.enable_full_season || false; + const autoCache = serviceEntry.auto_cache || false; addDebridServiceEntry( serviceEntry.service, serviceEntry.api_key, @@ -3789,6 +3860,7 @@ nzbdavUrlValue, enableNzb, enableFullSeason, + autoCache, webdavUserValue, webdavPasswordValue ); diff --git a/wastream/utils/validators.py b/wastream/utils/validators.py index 39a16e1..a220a01 100644 --- a/wastream/utils/validators.py +++ b/wastream/utils/validators.py @@ -22,6 +22,7 @@ class DebridServiceEntry(BaseModel): sources: List[str] = [] enable_nzb: bool = False enable_full_season: bool = False + auto_cache: bool = False @field_validator("service") @classmethod -- 2.45.2 From 1bf15ab7ddfa88d54681a64ec4dc3a7cf3188afb Mon Sep 17 00:00:00 2001 From: 10ho <10ho@users.noreply.gitlab.com> Date: Tue, 19 May 2026 20:13:35 +0200 Subject: [PATCH 2/2] 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 --- wastream/config/settings.py | 2 +- wastream/debrid/alldebrid.py | 20 ++++++++++++++++ wastream/debrid/onefichier.py | 6 ++--- wastream/debrid/premiumize.py | 38 +++++++++++++++++-------------- wastream/debrid/torbox.py | 11 +++++---- wastream/main.py | 34 ++++++++++++++++++++++++++- wastream/templates/dashboard.html | 22 +++++++++++++++++- wastream/utils/languages.py | 17 ++++++++++++++ wastream/utils/logger.py | 25 ++++++++++++++++---- 9 files changed, 144 insertions(+), 31 deletions(-) diff --git a/wastream/config/settings.py b/wastream/config/settings.py index 20b1f98..f00069a 100644 --- a/wastream/config/settings.py +++ b/wastream/config/settings.py @@ -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"], diff --git a/wastream/debrid/alldebrid.py b/wastream/debrid/alldebrid.py index d773f53..2b59c22 100644 --- a/wastream/debrid/alldebrid.py +++ b/wastream/debrid/alldebrid.py @@ -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.", "") diff --git a/wastream/debrid/onefichier.py b/wastream/debrid/onefichier.py index 79aec25..6224065 100644 --- a/wastream/debrid/onefichier.py +++ b/wastream/debrid/onefichier.py @@ -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") diff --git a/wastream/debrid/premiumize.py b/wastream/debrid/premiumize.py index 5009143..2c565e9 100644 --- a/wastream/debrid/premiumize.py +++ b/wastream/debrid/premiumize.py @@ -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": diff --git a/wastream/debrid/torbox.py b/wastream/debrid/torbox.py index 56dd211..3612d28 100644 --- a/wastream/debrid/torbox.py +++ b/wastream/debrid/torbox.py @@ -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 diff --git a/wastream/main.py b/wastream/main.py index 465d1cc..3d8ac3d 100644 --- a/wastream/main.py +++ b/wastream/main.py @@ -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) # =========================== diff --git a/wastream/templates/dashboard.html b/wastream/templates/dashboard.html index 9c2fb6a..fcc8729 100644 --- a/wastream/templates/dashboard.html +++ b/wastream/templates/dashboard.html @@ -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 + ? `👤 ${escapeHtml(log.user_id.substring(0, 8))}` + : ''; + entry.innerHTML = `
${escapeHtml(log.time)} ${log.level_icon} ${log.level} ${log.context_icon} ${log.context} + ${userIdBadge}
${escapeHtml(log.message)} `; diff --git a/wastream/utils/languages.py b/wastream/utils/languages.py index e5e20f9..ac50697 100644 --- a/wastream/utils/languages.py +++ b/wastream/utils/languages.py @@ -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 diff --git a/wastream/utils/logger.py b/wastream/utils/logger.py index 6a92136..a480ae3 100644 --- a/wastream/utils/logger.py +++ b/wastream/utils/logger.py @@ -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"👤 {user_id[:8]} | " if user_id else "" + return ( "{time:YYYY-MM-DD} " "{time:HH:mm:ss} | " f"{level_icon} {{level: <8}} | " f"<{context_color}>{context_icon} {{extra[context]: <10}} | " + f"{user_segment}" "{message}\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(): -- 2.45.2