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