feat(torbox): Implement Payload Slicing and Auto-Cacher Toggle #2

Open
Leadernelson wants to merge 2 commits from main into main
5 changed files with 170 additions and 16 deletions

View file

@ -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")

View file

@ -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',

View file

@ -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

View file

@ -1768,6 +1768,22 @@
</div>
<input type="checkbox" class="service-enable-fullseason" style="display: none;" />
</div>
<div class="form-field service-autocache-toggle" style="display: none;">
<div class="toggle-wrapper">
<div class="toggle service-autocache-toggle-btn">
<svg class="toggle-icon left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
<svg class="toggle-icon right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<span class="toggle-label" data-i18n="toggles.autoCache">Auto-cache best link (TorBox)</span>
</div>
<input type="checkbox" class="service-enable-autocache" style="display: none;" />
<span class="field-help" style="margin-top: 6px; display: block;" data-i18n="help.autoCacheInfo">Automatically triggers a background download for the best quality link if no streams are cached.</span>
</div>
</div>
</div>
<div class="form-field">
@ -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 @@
</div>
<input type="checkbox" class="service-enable-fullseason" style="display: none;" ${enableFullSeason ? 'checked' : ''} />
</div>
<div class="form-field service-autocache-toggle" style="display: ${service === 'torbox' ? 'block' : 'none'};">
<div class="toggle-wrapper">
<div class="toggle service-autocache-toggle-btn ${autoCache ? 'active' : ''}">
<svg class="toggle-icon left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
<svg class="toggle-icon right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<span class="toggle-label" data-i18n="toggles.autoCache">${trans.toggles?.autoCache || 'Auto-cache best link (TorBox)'}</span>
</div>
<input type="checkbox" class="service-enable-autocache" style="display: none;" ${autoCache ? 'checked' : ''} />
<span class="field-help" style="margin-top: 6px; display: block;" data-i18n="help.autoCacheInfo">${trans.help?.autoCacheInfo || 'Automatically triggers a background download for the best quality link if no streams are cached.'}</span>
</div>
`;
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
);

View file

@ -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