Compare commits

...

24 commits
master ... dev

Author SHA1 Message Date
Telkaoss
1593dcc662
Update torbox.py 2025-07-05 19:26:26 +02:00
Telkaoss
e38a6b3cff
Update filter_results.py 2025-07-04 13:28:13 +02:00
Telkaoss
947f343919
Update torrent_service.py 2025-07-04 13:25:32 +02:00
Telkaoss
9a0a41d6cb
Update torbox.py 2025-07-04 13:24:51 +02:00
Telkaoss
011502a94f
Update alldebrid.py 2025-07-04 13:23:49 +02:00
Telkaoss
322f5f19e4
Update index.html 2025-05-27 16:16:59 +02:00
Telkaoss
a384077c28
Update config.js 2025-05-27 16:16:42 +02:00
Telkaoss
7804bfc735
Update and rename docker-develop.yml to docker-develop.yml.disable 2025-05-11 03:48:56 +02:00
Telkaoss
47a3774e28
Update config.js 2025-05-11 03:39:30 +02:00
Telkaoss
6af194843f
Update index.html 2025-05-11 03:39:17 +02:00
Telkaoss
600c2c8895
Update index.html 2025-05-09 23:43:06 +02:00
Telkaoss
4ac806d326
Update config.js 2025-05-09 23:42:49 +02:00
Telkaoss
76ae1ecfb7
Update views.py 2025-05-02 14:49:30 +02:00
Telkaoss
1c667d3ba7
Update index.html 2025-05-02 14:47:25 +02:00
Telkaoss
1452483bfd
Update config.js 2025-05-02 14:47:02 +02:00
Telkaoss
3e84b8dc09
Update alldebrid.py 2025-05-02 14:46:38 +02:00
Telkaoss
5e05d61f14
Update base_debrid.py 2025-05-02 14:46:13 +02:00
Telkaoss
97d68ca8eb
Update torrent_smart_container.py 2025-05-02 14:45:40 +02:00
Telkaoss
d7d8c89eec
Update torrent_item.py 2025-05-02 14:44:54 +02:00
Telkaoss
7a7fe8c773
Update views.py 2025-05-02 14:44:25 +02:00
Telkaoss
83636a71e9
Update get_debrid_service.py 2025-05-02 14:43:50 +02:00
Telkaoss
042c89d195
Update stremio_parser.py 2025-05-02 14:43:25 +02:00
Telkaoss
91c7ec05d4
Update local_redis.py 2025-05-02 14:43:01 +02:00
Telkaoss
e30ce50718
Create stremtrudebrid.py 2025-05-02 14:42:02 +02:00
16 changed files with 1688 additions and 346 deletions

View file

@ -3,7 +3,7 @@ name: Build and Push Docker image
on:
push:
branches:
- develop
- dev
jobs:
build:

View file

@ -1,12 +1,29 @@
const sorts = ['quality', 'sizedesc', 'sizeasc', 'qualitythensize'];
const qualityExclusions = ['2160p', '1080p', '720p', '480p', 'rips', 'cam', 'hevc', 'unknown'];
const languages = ['en', 'fr', 'multi'];
const languages = ['en', 'fr', 'multi', 'vfq'];
const implementedDebrids = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix'];
const unimplementedDebrids = ['debrid_dl', 'debrid_ed', 'debrid_oc', 'debrid_pk'];
document.addEventListener('DOMContentLoaded', function () {
loadData();
handleUniqueAccounts();
updateProviderFields();
updateDebridOrderList();
toggleStremThruFields();
const apiKeyInput = document.getElementById('ApiKey');
if (apiKeyInput) {
apiKeyInput.addEventListener('blur', function() {
validateApiKeyWithoutAlert(this.value);
});
if (apiKeyInput.value && apiKeyInput.value.trim() !== '') {
validateApiKeyWithoutAlert(apiKeyInput.value);
}
}
});
function setElementDisplay(elementId, displayStatus) {
@ -181,7 +198,7 @@ function pollForADCredentials(check, pin, expiresIn) {
return response.json();
})
.then(data => {
if (data === null) return; // Skip processing if user hasn't entered PIN yet
if (data === null) return;
if (data.data && data.data.activated && data.data.apikey) {
clearInterval(pollInterval);
clearTimeout(timeoutId);
@ -253,26 +270,42 @@ function updateDebridOrderList() {
const adEnabled = document.getElementById('debrid_ad').checked || document.getElementById('debrid_ad').disabled;
const tbEnabled = document.getElementById('debrid_tb').checked || document.getElementById('debrid_tb').disabled;
const pmEnabled = document.getElementById('debrid_pm').checked || document.getElementById('debrid_pm').disabled;
const dlEnabled = document.getElementById('debrid_dl')?.checked || document.getElementById('debrid_dl')?.disabled;
const edEnabled = document.getElementById('debrid_ed')?.checked || document.getElementById('debrid_ed')?.disabled;
const ocEnabled = document.getElementById('debrid_oc')?.checked || document.getElementById('debrid_oc')?.disabled;
const pkEnabled = document.getElementById('debrid_pk')?.checked || document.getElementById('debrid_pk')?.disabled;
if (debridOrder.length === 0 ||
!debridOrder.every(service =>
(service === 'Real-Debrid' && rdEnabled) ||
(service === 'AllDebrid' && adEnabled) ||
(service === 'TorBox' && tbEnabled) ||
(service === 'Premiumize' && pmEnabled)
(service === 'Premiumize' && pmEnabled) ||
(service === 'Debrid-Link' && dlEnabled) ||
(service === 'EasyDebrid' && edEnabled) ||
(service === 'Offcloud' && ocEnabled) ||
(service === 'PikPak' && pkEnabled)
)) {
debridOrder = [];
if (rdEnabled) debridOrder.push('Real-Debrid');
if (adEnabled) debridOrder.push('AllDebrid');
if (tbEnabled) debridOrder.push('TorBox');
if (pmEnabled) debridOrder.push('Premiumize');
if (dlEnabled) debridOrder.push('Debrid-Link');
if (edEnabled) debridOrder.push('EasyDebrid');
if (ocEnabled) debridOrder.push('Offcloud');
if (pkEnabled) debridOrder.push('PikPak');
}
debridOrder.forEach(serviceName => {
if ((serviceName === 'Real-Debrid' && rdEnabled) ||
(serviceName === 'AllDebrid' && adEnabled) ||
(serviceName === 'Premiumize' && pmEnabled) ||
(serviceName === 'TorBox' && tbEnabled)) {
(serviceName === 'TorBox' && tbEnabled) ||
(serviceName === 'Debrid-Link' && dlEnabled) ||
(serviceName === 'EasyDebrid' && edEnabled) ||
(serviceName === 'Offcloud' && ocEnabled) ||
(serviceName === 'PikPak' && pkEnabled)) {
addDebridToList(serviceName);
}
});
@ -289,6 +322,18 @@ function updateDebridOrderList() {
if (pmEnabled && !debridOrder.includes('Premiumize')) {
addDebridToList('Premiumize');
}
if (dlEnabled && !debridOrder.includes('Debrid-Link')) {
addDebridToList('Debrid-Link');
}
if (edEnabled && !debridOrder.includes('EasyDebrid')) {
addDebridToList('EasyDebrid');
}
if (ocEnabled && !debridOrder.includes('Offcloud')) {
addDebridToList('Offcloud');
}
if (pkEnabled && !debridOrder.includes('PikPak')) {
addDebridToList('PikPak');
}
Sortable.create(debridOrderList, {
animation: 150,
@ -330,40 +375,103 @@ function toggleDebridOrderList() {
}
}
function toggleStremThruFields() {
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
if (!stremthruEnabledCheckbox) return;
const isEnabled = stremthruEnabledCheckbox.checked;
const urlDiv = document.getElementById('stremthru_url_div');
const authDiv = document.getElementById('stremthru_auth_div');
const urlInput = document.getElementById('stremthru_url');
const defaultUrl = 'https://stremthru.13377001.xyz/';
if (isEnabled) {
setElementDisplay('stremthru_url_div', 'block');
if (authDiv) setElementDisplay('stremthru_auth_div', 'block');
// Set default URL if empty or placeholder
if (urlInput && (!urlInput.value || urlInput.value === urlInput.placeholder)) {
urlInput.value = defaultUrl;
}
} else {
setElementDisplay('stremthru_url_div', 'none');
if (authDiv) setElementDisplay('stremthru_auth_div', 'none');
// Si StremThru est désactivé, désactiver et décocher les services non implémentés
unimplementedDebrids.forEach(id => {
const checkbox = document.getElementById(id);
if (checkbox && checkbox.checked) {
checkbox.checked = false;
// Déclencher manuellement la mise à jour pour masquer les champs
updateProviderFields();
}
});
}
}
function updateDebridDownloaderOptions() {
const debridDownloaderOptions = document.getElementById('debridDownloaderOptions');
if (!debridDownloaderOptions) return;
debridDownloaderOptions.innerHTML = '';
const rdEnabled = document.getElementById('debrid_rd').checked || document.getElementById('debrid_rd').disabled;
const adEnabled = document.getElementById('debrid_ad').checked || document.getElementById('debrid_ad').disabled;
const tbEnabled = document.getElementById('debrid_tb').checked || document.getElementById('debrid_tb').disabled;
const pmEnabled = document.getElementById('debrid_pm').checked || document.getElementById('debrid_pm').disabled;
// --- Vérifier les services de débridage standard ---
const rdEnabled = document.getElementById('debrid_rd')?.checked || document.getElementById('debrid_rd')?.disabled;
const adEnabled = document.getElementById('debrid_ad')?.checked || document.getElementById('debrid_ad')?.disabled;
const tbEnabled = document.getElementById('debrid_tb')?.checked || document.getElementById('debrid_tb')?.disabled;
const pmEnabled = document.getElementById('debrid_pm')?.checked || document.getElementById('debrid_pm')?.disabled;
const dlEnabled = document.getElementById('debrid_dl')?.checked || document.getElementById('debrid_dl')?.disabled;
const edEnabled = document.getElementById('debrid_ed')?.checked || document.getElementById('debrid_ed')?.disabled;
const ocEnabled = document.getElementById('debrid_oc')?.checked || document.getElementById('debrid_oc')?.disabled;
const pkEnabled = document.getElementById('debrid_pk')?.checked || document.getElementById('debrid_pk')?.disabled;
// --- Vérifier StremThru ---
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
const stremthruEnabled = stremthruEnabledCheckbox ? stremthruEnabledCheckbox.checked : false;
let firstOption = null;
// --- Ajouter des options en fonction des services activés ---
if (rdEnabled) {
firstOption = addDebridDownloaderOption('Real-Debrid');
}
if (adEnabled) {
firstOption = addDebridDownloaderOption('AllDebrid');
// Utiliser l'opérateur ternaire pour une attribution plus propre
firstOption = firstOption ? firstOption : addDebridDownloaderOption('AllDebrid');
if (firstOption.value !== 'AllDebrid') addDebridDownloaderOption('AllDebrid');
}
if (tbEnabled) {
if (!firstOption) {
firstOption = addDebridDownloaderOption('TorBox');
} else {
addDebridDownloaderOption('TorBox');
}
firstOption = firstOption ? firstOption : addDebridDownloaderOption('TorBox');
if (firstOption.value !== 'TorBox') addDebridDownloaderOption('TorBox');
}
if (pmEnabled) {
if (!firstOption) {
firstOption = addDebridDownloaderOption('Premiumize');
} else {
addDebridDownloaderOption('Premiumize');
}
firstOption = firstOption ? firstOption : addDebridDownloaderOption('Premiumize');
if (firstOption.value !== 'Premiumize') addDebridDownloaderOption('Premiumize');
}
if (dlEnabled) {
firstOption = firstOption ? firstOption : addDebridDownloaderOption('Debrid-Link');
if (firstOption.value !== 'Debrid-Link') addDebridDownloaderOption('Debrid-Link');
}
if (edEnabled) {
firstOption = firstOption ? firstOption : addDebridDownloaderOption('EasyDebrid');
if (firstOption.value !== 'EasyDebrid') addDebridDownloaderOption('EasyDebrid');
}
if (ocEnabled) {
firstOption = firstOption ? firstOption : addDebridDownloaderOption('Offcloud');
if (firstOption.value !== 'Offcloud') addDebridDownloaderOption('Offcloud');
}
if (pkEnabled) {
firstOption = firstOption ? firstOption : addDebridDownloaderOption('PikPak');
if (firstOption.value !== 'PikPak') addDebridDownloaderOption('PikPak');
}
// --- Ajouter StremThru si activé ---
if (stremthruEnabled) {
firstOption = firstOption ? firstOption : addDebridDownloaderOption('StremThru');
if (firstOption.value !== 'StremThru') addDebridDownloaderOption('StremThru');
}
// Sélectionner la première option ajoutée par défaut si aucune n'est sélectionnée
if (firstOption && !document.querySelector('input[name="debrid_downloader"]:checked')) {
firstOption.checked = true;
}
@ -397,33 +505,91 @@ function addDebridDownloaderOption(serviceName) {
function updateProviderFields() {
const RDdebridChecked = document.getElementById('debrid_rd').checked ||
document.getElementById('debrid_rd').disabled;
const ADdebridChecked = document.getElementById('debrid_ad').checked ||
document.getElementById('debrid_ad').disabled;
const TBdebridChecked = document.getElementById('debrid_tb').checked ||
document.getElementById('debrid_tb').disabled;
const PMdebridChecked = document.getElementById('debrid_pm').checked ||
document.getElementById('debrid_pm').disabled;
const cacheChecked = document.getElementById('cache')?.checked;
const yggflixChecked = document.getElementById('yggflix')?.checked ||
document.getElementById('yggflix')?.disabled;
const sharewoodChecked = document.getElementById('sharewood')?.checked ||
document.getElementById('sharewood')?.disabled;
console.log("--- Running updateProviderFields ---"); // Debug start
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
let stremthruWasEnabled = stremthruEnabledCheckbox ? stremthruEnabledCheckbox.checked : false; // Track initial state
let stremthruForcedEnable = false;
let anyUnimplementedChecked = false; // Flag to track if any unimplemented service is checked
setElementDisplay('rd_debrid-fields', RDdebridChecked ? 'block' : 'none');
setElementDisplay('ad_debrid-fields', ADdebridChecked ? 'block' : 'none');
setElementDisplay('tb_debrid-fields', TBdebridChecked ? 'block' : 'none');
setElementDisplay('pm_debrid-fields', PMdebridChecked ? 'block' : 'none');
const serviceStates = {};
const allDebrids = [...implementedDebrids, ...unimplementedDebrids];
// Vérifier l'état des autres éléments de l'interface
const cacheChecked = document.getElementById('cache')?.checked;
const yggflixChecked = document.getElementById('yggflix')?.checked || document.getElementById('yggflix')?.disabled;
const sharewoodChecked = document.getElementById('sharewood')?.checked || document.getElementById('sharewood')?.disabled;
const torboxChecked = document.getElementById('debrid_tb')?.checked || document.getElementById('debrid_tb')?.disabled;
// Afficher/masquer les champs spécifiques
setElementDisplay('cache-fields', cacheChecked ? 'block' : 'none');
setElementDisplay('ygg-fields', yggflixChecked ? 'block' : 'none');
setElementDisplay('sharewood-fields', sharewoodChecked ? 'block' : 'none');
setElementDisplay('tb_debrid-fields', torboxChecked ? 'block' : 'none');
// Traiter tous les débrideurs
allDebrids.forEach(id => {
const checkbox = document.getElementById(id);
if (!checkbox) return;
const isChecked = checkbox.checked || checkbox.disabled;
serviceStates[id] = isChecked;
// Déterminer l'ID du div de credentials correspondant
let credDivId = '';
switch (id) {
case 'debrid_rd': credDivId = 'rd_token_info_div'; break;
case 'debrid_ad': credDivId = 'ad_token_info_div'; break;
case 'debrid_pm': credDivId = 'pm_token_info_div'; break;
case 'debrid_tb': credDivId = 'tb_token_info_div'; break;
case 'debrid_dl': credDivId = 'debridlink_api_key_div'; break;
case 'debrid_ed': credDivId = 'easydebrid_api_key_div'; break;
case 'debrid_oc': credDivId = 'offcloud_credentials_div'; break;
case 'debrid_pk': credDivId = 'pikpak_credentials_div'; break;
}
// Afficher/masquer le div de credentials
if (credDivId) {
setElementDisplay(credDivId, isChecked ? 'block' : 'none');
}
// Logique pour forcer l'activation de StremThru avec les débrideurs non implémentés
if (unimplementedDebrids.includes(id) && isChecked) {
if (stremthruEnabledCheckbox && !stremthruEnabledCheckbox.checked) {
stremthruEnabledCheckbox.checked = true;
stremthruForcedEnable = true; // Marquer qu'on l'a forcé
}
anyUnimplementedChecked = true; // Définir le drapeau
}
});
// Gérer l'état de la case à cocher StremThru: désactiver si un service non implémenté est coché
if (stremthruEnabledCheckbox) {
if (anyUnimplementedChecked) {
stremthruEnabledCheckbox.checked = true; // S'assurer qu'elle est cochée
stremthruEnabledCheckbox.disabled = true; // Désactiver la case à cocher
} else {
stremthruEnabledCheckbox.disabled = false; // Réactiver si aucun service non implémenté n'est coché
}
// Afficher/masquer les champs StremThru
setElementDisplay('stremthru_url_div', stremthruEnabledCheckbox.checked ? 'block' : 'none');
const authDiv = document.getElementById('stremthru_auth_div');
if (authDiv) {
setElementDisplay('stremthru_auth_div', stremthruEnabledCheckbox.checked ? 'block' : 'none');
}
}
// Si on a forcé l'activation de StremThru OU si son état a changé, mettre à jour la visibilité de ses champs
if (stremthruEnabledCheckbox && (stremthruForcedEnable || stremthruEnabledCheckbox.checked !== stremthruWasEnabled || anyUnimplementedChecked)) {
toggleStremThruFields();
}
// Gérer l'ordre des débrideurs
const debridOrderCheckbox = document.getElementById('debrid_order');
const debridOrderList = document.getElementById('debridOrderList');
if (debridOrderCheckbox && debridOrderList) {
const anyDebridEnabled = RDdebridChecked || ADdebridChecked || TBdebridChecked || PMdebridChecked;
// Vérifier si au moins un débrideur est activé
const anyDebridEnabled = Object.values(serviceStates).some(state => state);
debridOrderCheckbox.disabled = !anyDebridEnabled;
@ -434,26 +600,57 @@ function updateProviderFields() {
debridOrderList.classList.toggle('hidden', !(anyDebridEnabled && debridOrderCheckbox.checked));
}
// Mettre à jour les listes et options
updateDebridOrderList();
updateDebridDownloaderOptions();
ensureDebridConsistency();
console.log("--- Finished updateProviderFields ---"); // Debug end
}
function ensureDebridConsistency() {
const RDdebridChecked = document.getElementById('debrid_rd').checked;
const ADdebridChecked = document.getElementById('debrid_ad').checked;
const TBdebridChecked = document.getElementById('debrid_tb').checked;
const PMdebridChecked = document.getElementById('debrid_pm').checked;
const debridOrderChecked = document.getElementById('debrid_order').checked;
// Récupérer l'état de tous les débrideurs
const serviceStates = {};
const allDebrids = [...implementedDebrids, ...unimplementedDebrids];
let anyDebridChecked = false;
let anyUnimplementedChecked = false;
if (!RDdebridChecked && !ADdebridChecked && !TBdebridChecked && !PMdebridChecked) {
document.getElementById('debrid_order').checked = false;
document.getElementById('debridOrderList').classList.add('hidden');
allDebrids.forEach(id => {
const checkbox = document.getElementById(id);
if (!checkbox) return;
const isChecked = checkbox.checked;
serviceStates[id] = isChecked;
if (isChecked) {
anyDebridChecked = true;
if (unimplementedDebrids.includes(id)) {
anyUnimplementedChecked = true;
}
}
});
// Gérer l'état de la case à cocher d'ordre des débrideurs
const debridOrderCheckbox = document.getElementById('debrid_order');
const debridOrderList = document.getElementById('debridOrderList');
if (debridOrderCheckbox && debridOrderList) {
if (!anyDebridChecked) {
debridOrderCheckbox.checked = false;
debridOrderList.classList.add('hidden');
}
if (debridOrderCheckbox.checked && !anyDebridChecked) {
debridOrderCheckbox.checked = false;
}
}
if (debridOrderChecked && !RDdebridChecked && !ADdebridChecked && !TBdebridChecked && !PMdebridChecked) {
document.getElementById('debrid_order').checked = false;
// Gérer l'état de la case à cocher StremThru
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
if (stremthruEnabledCheckbox) {
if (anyUnimplementedChecked) {
stremthruEnabledCheckbox.checked = true;
stremthruEnabledCheckbox.disabled = true;
} else {
stremthruEnabledCheckbox.disabled = false;
}
}
updateDebridDownloaderOptions();
@ -497,8 +694,8 @@ function loadData() {
ctg_yggtorrent: true,
ctg_yggflix: false,
metadataProvider: 'tmdb',
sort: 'qualitythensize',
exclusion: ['cam', '2160p'],
sort: 'quality',
exclusion: ['cam'],
languages: ['fr', 'multi'],
debrid_rd: false,
debrid_ad: false,
@ -569,10 +766,50 @@ function loadData() {
ensureDebridConsistency();
}
// Fonction pour valider l'API key
function validateApiKey(apiKey) {
// Référence à l'élément d'erreur
const apiKeyErrorElement = document.getElementById('apiKeyError');
// Si aucune API key n'est fournie
if (!apiKey || apiKey.trim() === '') {
if (apiKeyErrorElement) {
apiKeyErrorElement.classList.remove('hidden');
}
alert('Veuillez fournir une API Key Stream Fusion. Utilisez le bot Telegram : t.me/Stremiofr_bot - Envoyez la commande /generate');
return false;
}
// Vérification du format UUID v4
const isValidFormat = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(apiKey);
if (!isValidFormat) {
if (apiKeyErrorElement) {
apiKeyErrorElement.classList.remove('hidden');
}
alert('API Key invalide. Utilisez le bot Telegram : t.me/Stremiofr_bot - Envoyez la commande /generate');
return false;
}
// Si l'API key est valide, masquer le message d'erreur
if (apiKeyErrorElement) {
apiKeyErrorElement.classList.add('hidden');
}
return true;
}
function getLink(method) {
const apiKey = document.getElementById('ApiKey').value;
// Vérifier l'API key en premier
if (!validateApiKey(apiKey)) {
return false;
}
const data = {
addonHost: new URL(window.location.href).origin,
apiKey: document.getElementById('ApiKey').value,
apiKey: apiKey,
service: [],
RDToken: document.getElementById('rd_token_info')?.value,
ADToken: document.getElementById('ad_token_info')?.value,
@ -601,7 +838,15 @@ function getLink(method) {
torrenting: document.getElementById('torrenting').checked,
debrid: false,
metadataProvider: document.getElementById('tmdb').checked ? 'tmdb' : 'cinemeta',
debridDownloader: document.querySelector('input[name="debrid_downloader"]:checked')?.value
debridDownloader: document.querySelector('input[name="debrid_downloader"]:checked')?.value,
// StremThru configuration
stremthru: document.getElementById('stremthru_enabled')?.checked || false,
stremthruUrl: document.getElementById('stremthru_url')?.value || 'https://stremthru.13377001.xyz',
// Nouveaux débrideurs
debridlinkApiKey: document.getElementById('debridlink_api_key')?.value || '',
easydebridApiKey: document.getElementById('easydebrid_api_key')?.value || '',
offcloudCredentials: document.getElementById('offcloud_credentials')?.value || '',
pikpakCredentials: document.getElementById('pikpak_credentials')?.value || ''
};
data.service = Array.from(document.getElementById('debridOrderList').children).map(li => li.dataset.serviceName);
@ -614,9 +859,14 @@ function getLink(method) {
if (data.service.includes('AllDebrid') && document.getElementById('ad_token_info') && !data.ADToken) missingRequiredFields.push("AllDebrid Account Connection");
if (data.service.includes('TorBox') && document.getElementById('tb_token_info') && !data.TBToken) missingRequiredFields.push("TorBox Account Connection");
if (data.service.includes('Premiumize') && document.getElementById('pm_token_info') && !data.PMToken) missingRequiredFields.push("Premiumize Account Connection");
if (data.service.includes('Debrid-Link') && document.getElementById('debridlink_api_key') && !data.debridlinkApiKey) missingRequiredFields.push("Debrid-Link API Key");
if (data.service.includes('EasyDebrid') && document.getElementById('easydebrid_api_key') && !data.easydebridApiKey) missingRequiredFields.push("EasyDebrid API Key");
if (data.service.includes('Offcloud') && document.getElementById('offcloud_credentials') && !data.offcloudCredentials) missingRequiredFields.push("Offcloud Credentials");
if (data.service.includes('PikPak') && document.getElementById('pikpak_credentials') && !data.pikpakCredentials) missingRequiredFields.push("PikPak Credentials");
if (data.languages.length === 0) missingRequiredFields.push("Languages");
if (data.yggflix && document.getElementById('yggPasskey') && !data.yggPasskey) missingRequiredFields.push("Ygg Passkey");
if (data.sharewood && document.getElementById('sharewoodPasskey') && !data.sharewoodPasskey) missingRequiredFields.push("Sharewood Passkey");
if (data.stremthru && !data.stremthruUrl) missingRequiredFields.push("StremThru URL");
if (missingRequiredFields.length > 0) {
alert(`Please fill all required fields: ${missingRequiredFields.join(", ")}`);
@ -627,10 +877,6 @@ function getLink(method) {
return /^[a-zA-Z0-9]{32}$/.test(passkey);
}
function validateApiKey(apiKey) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(apiKey);
}
if (data.yggflix && data.yggPasskey && !validatePasskey(data.yggPasskey)) {
alert('Ygg Passkey doit contenir exactement 32 caractères alphanumériques');
return false;
@ -641,11 +887,6 @@ function getLink(method) {
return false;
}
if (data.apiKey && !validateApiKey(data.apiKey)) {
alert('APIKEY doit être un UUID v4 valide');
return false;
}
const encodedData = btoa(JSON.stringify(data));
const stremio_link = `${window.location.host}/${encodedData}/manifest.json`;
@ -667,3 +908,33 @@ function showCheckboxes() {
checkboxes.style.display = showLanguageCheckBoxes ? "block" : "none";
showLanguageCheckBoxes = !showLanguageCheckBoxes;
}
// Fonction pour valider l'API key sans afficher d'alert
function validateApiKeyWithoutAlert(apiKey) {
const apiKeyErrorElement = document.getElementById('apiKeyError');
// Si aucune API key n'est fournie
if (!apiKey || apiKey.trim() === '') {
if (apiKeyErrorElement) {
apiKeyErrorElement.classList.remove('hidden');
}
return false;
}
// Vérification du format UUID v4
const isValidFormat = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(apiKey);
if (!isValidFormat) {
if (apiKeyErrorElement) {
apiKeyErrorElement.classList.remove('hidden');
}
return false;
}
// Si l'API key est valide, masquer le message d'erreur
if (apiKeyErrorElement) {
apiKeyErrorElement.classList.add('hidden');
}
return true;
}

View file

@ -51,8 +51,18 @@
<label for="ApiKey" class="block text-sm font-medium leading-6 text-red-600 text-center">API Key</label>
<input type="text" name="ApiKey" id="ApiKey"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
<div id="apiKeyError" class="hidden mt-2 text-sm text-center text-red-600">
<p>API Key manquante ou invalide</p>
</div>
</div>
</div>
<div class="mt-4 text-center">
<p class="text-sm text-gray-200 bg-gray-800 rounded-lg p-3 shadow-md">
<span class="font-bold text-white">Comment obtenir votre API Key ?</span><br>
1. Ajoutez le Bot Telegram : <a href="https://t.me/Stremiofr_bot" target="_blank" class="text-blue-400 hover:text-blue-300 underline font-medium">t.me/Stremiofr_bot</a><br>
2. Envoyez la commande <span class="bg-blue-900 text-white px-2 py-1 rounded font-mono">/generate</span>
</p>
</div>
</div>
</div>
</div>
@ -70,13 +80,27 @@
<input id="debrid_rd" name="debrid_rd" type="checkbox"
data-unique-account="{{ rd_unique_account|lower }}"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields(true)">
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_rd" class="font-medium text-white">Enable Real-Debrid Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate Real-Débrid service for streaming.
</p>
</div>
<!-- RealDebrid Token Info -->
<div id="rd_token_info_div" class="mt-4 space-y-3" style="display: none;">
<p class="text-sm text-gray-400">Real-Debrid nécessite une authentification OAuth.</p>
<button type="button" id="rd-auth-button" onclick="startRealDebridAuth()"
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 transition ease-in-out duration-150">
S'authentifier avec Real-Debrid
</button>
<div id="auth-instructions" style="display: none;" class="text-sm text-gray-300">
<p>Veuillez visiter <a id="verification-url" href="#" target="_blank"
class="font-medium text-indigo-400 hover:text-indigo-300">l'URL de vérification</a>
et entrer le code: <strong id="user-code" class="text-white"></strong></p>
</div>
<input type="hidden" id="rd_token_info" name="rd_token_info">
</div>
</div>
<div class="relative flex gap-x-3">
@ -84,13 +108,27 @@
<input id="debrid_ad" name="debrid_ad" type="checkbox"
data-unique-account="{{ ad_unique_account|lower }}"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields(true)">
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_ad" class="font-medium text-white">Enable AllDebrid Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate AllDebrid service for streaming.
</p>
</div>
<!-- AllDebrid Token Info -->
<div id="ad_token_info_div" class="mt-4 space-y-3" style="display: none;">
<p class="text-sm text-gray-400">AllDebrid nécessite une authentification OAuth.</p>
<button type="button" id="ad-auth-button" onclick="startADAuth()"
class="rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600 transition ease-in-out duration-150">
Connect with AllDebrid
</button>
<div id="ad-auth-instructions" style="display: none;" class="text-sm text-gray-300">
<p>Please visit <a id="ad-verification-url" href="#" target="_blank"
class="font-medium text-orange-400 hover:text-orange-300">verification URL</a>
and enter the code: <strong id="ad-user-code" class="text-white"></strong></p>
</div>
<input type="hidden" id="ad_token_info" name="ad_token_info">
</div>
</div>
<div class="relative flex gap-x-3">
@ -98,12 +136,22 @@
<input id="debrid_pm" name="debrid_pm" type="checkbox"
data-unique-account="{{ pm_unique_account|lower }}"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields(true)">
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_pm" class="font-medium text-white">Enable Premiumize Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate Premiumize service for streaming.</p>
</div>
<!-- Premiumize Token Info -->
<div id="pm_token_info_div" class="mt-2" style="display: none;">
<label for="pm_token_info" class="block text-sm font-medium leading-6 text-white">Premiumize
API Key</label>
<input type="text" name="pm_token_info" id="pm_token_info"
placeholder="Votre clé API Premiumize"
class="mt-1 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
<p class="mt-1 text-xs text-gray-400">Trouvez votre clé API dans les paramètres de votre compte
Premiumize.</p>
</div>
</div>
<div class="relative flex gap-x-3">
@ -111,13 +159,93 @@
<input id="debrid_tb" name="debrid_tb" type="checkbox"
data-unique-account="{{ tb_unique_account|lower }}"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields(true)">
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_tb" class="font-medium text-white">Enable TorBox Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate TorBox service for streaming.
</p>
</div>
<!-- TorBox Token Info -->
<div id="tb_token_info_div" class="mt-2" style="display: none;">
<label for="tb_token_info" class="block text-sm font-medium leading-6 text-white">TorBox API
Key</label>
<input type="text" name="tb_token_info" id="tb_token_info" placeholder="Votre clé API TorBox"
class="mt-1 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
<p class="mt-1 text-xs text-gray-400">Disponible dans les paramètres de votre compte TorBox.</p>
</div>
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="debrid_dl" name="debrid_dl" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_dl" class="font-medium text-white">Enable Debrid-Link Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate Debrid-Link service for streaming.</p>
</div>
</div>
<div id="debridlink_api_key_div" class="mt-2 hidden">
<label for="debridlink_api_key" class="block text-sm font-medium leading-6 text-white">Debrid-Link API Key</label>
<input type="text" name="debridlink_api_key" id="debridlink_api_key"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="Enter your Debrid-Link API Key">
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="debrid_ed" name="debrid_ed" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_ed" class="font-medium text-white">Enable EasyDebrid Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate EasyDebrid service for streaming.</p>
</div>
</div>
<div id="easydebrid_api_key_div" class="mt-2 hidden">
<label for="easydebrid_api_key" class="block text-sm font-medium leading-6 text-white">EasyDebrid API Key</label>
<input type="text" name="easydebrid_api_key" id="easydebrid_api_key"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="Enter your EasyDebrid API Key">
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="debrid_oc" name="debrid_oc" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_oc" class="font-medium text-white">Enable Offcloud Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate Offcloud service for streaming.</p>
</div>
</div>
<div id="offcloud_credentials_div" class="mt-2 hidden">
<label for="offcloud_credentials" class="block text-sm font-medium leading-6 text-white">Offcloud Credentials (email:password)</label>
<input type="text" name="offcloud_credentials" id="offcloud_credentials"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="Enter your Offcloud email:password">
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="debrid_pk" name="debrid_pk" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="debrid_pk" class="font-medium text-white">Enable PikPak Service</label>
<p class="text-gray-500 dark:text-gray-300">Activate PikPak service for streaming.</p>
</div>
</div>
<div id="pikpak_credentials_div" class="mt-2 hidden">
<label for="pikpak_credentials" class="block text-sm font-medium leading-6 text-white">PikPak Credentials (email:password)</label>
<input type="text" name="pikpak_credentials" id="pikpak_credentials"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="Enter your PikPak email:password">
</div>
<div class="relative flex gap-x-3">
@ -126,15 +254,52 @@
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
</div>
<div class="text-sm leading-6">
<label for="torrenting" class="font-medium text-white">Enable Torrenting</label>
<p class="text-gray-500 dark:text-gray-300"><span
class="font-bold text-red-600 dark:text-red-500">Warning: This may be illegal in
<label for="torrenting" class="font-medium text-white">Enable Torrenting <span
class="text-red-500">(Risky)</span></label>
<p class="text-gray-500 dark:text-gray-300">Activate direct torrent streaming. <span
class="text-red-500">This may be illegal in
some countries. Use at your own risk.</span></p>
</div>
</div>
</div>
</div>
<!-- StremThru Section -->
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
<div class="flex flex-col items-center">
<h2 class="text-white font-semibold leading-7">StremThru Settings</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Configure StremThru as an optional interface.</p>
</div>
<div class="mt-10 space-y-10">
<!-- Enable StremThru Checkbox -->
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="stremthru_enabled" name="stremthru_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" onchange="toggleStremThruFields()">
</div>
<div class="text-sm leading-6">
<label for="stremthru_enabled" class="font-medium text-white">Enable StremThru</label>
<p class="text-gray-500 dark:text-gray-300">Use StremThru to manage debrid services.</p>
</div>
</div>
<!-- StremThru URL Input -->
<div id="stremthru_url_div" class="hidden">
<label for="stremthru_url" class="block text-sm font-medium leading-6 text-white">StremThru URL</label>
<input type="text" name="stremthru_url" id="stremthru_url"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="https://stremthru.13377001.xyz/">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Enter the URL of your StremThru instance.</p>
</div>
<!-- StremThru Store Auth Input -->
<div id="stremthru_auth_div" class="hidden">
<label for="stremthru_store_auth" class="block text-sm font-medium leading-6 text-white">StremThru Store Authorization (Optional)</label>
<input type="text" name="stremthru_store_auth" id="stremthru_store_auth"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="Optional global auth key">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Global key if required by your StremThru instance.</p>
</div>
</div>
</div>
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
<div class="flex flex-col items-center">
<h2 class="text-white font-semibold leading-7">Torrent Providers</h2>
@ -161,7 +326,7 @@
<div class="flex h-6 items-center">
<input id="cache" name="cache" type="checkbox" checked
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields(true)">
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="cache" class="font-medium text-white">Enable Public Caching</label>
@ -191,7 +356,7 @@
<input id="yggflix" name="yggflix" type="checkbox"
data-unique-account="{{ ygg_unique_account|lower }}"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields(true)">
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="yggflix" class="font-medium text-white">Enable YggTorrent API</label>
@ -205,7 +370,7 @@
<input id="sharewood" name="sharewood" type="checkbox"
data-unique-account="{{ sharewood_unique_account|lower }}"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
onchange="updateProviderFields(true)">
onchange="updateProviderFields()">
</div>
<div class="text-sm leading-6">
<label for="sharewood" class="font-medium text-white">Enable Sharewood API</label>
@ -326,20 +491,11 @@
</div>
{% endif %}
{% if not tb_unique_account %}
<div id="tb_debrid-fields" style="display: none;">
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
<div class="flex flex-col items-center">
<h2 class="text-white font-semibold leading-7">Torbox Configuration</h2>
</div>
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<div class="sm:col-span-4">
<label for="tb_token_info" class="block text-sm font-medium leading-6 text-white">Torbox
Token</label>
<input type="text" name="tb_token_info" id="tb_token_info"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
</div>
</div>
<div class="mt-6 space-y-6">
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
@ -367,7 +523,6 @@
</div>
</div>
</div>
{% endif %}
{% if not pm_unique_account %}
<div id="pm_debrid-fields" style="display: none;">
@ -526,7 +681,7 @@
<legend class="text-sm font-semibold leading-6 text-white">Sorting Options</legend>
<div class="mt-6 space-y-6">
<div class="flex items-center gap-x-3">
<input id="quality" name="sorting" type="radio"
<input id="quality" name="sorting" type="radio" checked
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="quality" class="block text-sm font-medium leading-6 text-white">Get the
best
@ -547,7 +702,7 @@
size ascending</label>
</div>
<div class="flex items-center gap-x-3">
<input id="qualitythensize" name="sorting" type="radio" checked
<input id="qualitythensize" name="sorting" type="radio"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="qualitythensize"
class="block text-sm font-medium leading-6 text-white">Filter
@ -600,7 +755,7 @@
want to exclude</p>
<div class="mt-6 space-y-6">
<div class="flex items-center gap-x-3">
<input id="2160p" name="2160p" type="checkbox" checked
<input id="2160p" name="2160p" type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="2160p" class="block text-sm font-medium leading-6 text-white">Exclude
2160p
@ -621,7 +776,7 @@
resolution</label>
</div>
<div class="flex items-center gap-x-3">
<input id="480p" name="480p" type="checkbox"
<input id="480p" name="480p" type="checkbox" checked
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
<label for="480p" class="block text-sm font-medium leading-6 text-white">Exclude
480p
@ -701,6 +856,12 @@
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<span class="ml-2 text-white">MULTi</span>
</label>
<label class="inline-flex items-center">
<input id="vfq" type="checkbox" name="language" value="vfq"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<span class="ml-2 text-white">Français québécois (VFQ)</span>
<span class="ml-2 text-xs text-gray-400">(Juste pour les québécois - VFQ en priorité)</span>
</label>
</div>
</div>
</div>
@ -754,14 +915,13 @@
</div>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-center gap-x-6">
<a id="install" onclick="return getLink('link')"
class="cursor-pointer rounded-md bg-black px-3 py-2 text-sm font-semibold text-white dark:text-black dark:bg-white shadow-sm hover:bg--500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Install</a>
<a id="copy" onclick="return getLink('copy')"
class="cursor-pointer rounded-md bg-black px-3 py-2 text-sm font-semibold text-white dark:text-black dark:bg-white shadow-sm hover:bg--500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Copy</a>
</div>
<div class="mt-6 flex items-center justify-center gap-x-6">
<a id="install" onclick="return getLink('link');"
class="cursor-pointer rounded-md bg-black px-3 py-2 text-sm font-semibold text-white dark:text-black dark:bg-white shadow-sm hover:bg--500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Install</a>
<a id="copy" onclick="return getLink('copy');"
class="cursor-pointer rounded-md bg-black px-3 py-2 text-sm font-semibold text-white dark:text-black dark:bg-white shadow-sm hover:bg--500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Copy</a>
</div>
</div>
</form>
</div>

View file

@ -187,11 +187,21 @@ class RedisCache(CacheBase):
await self.execute_with_retry(set_operation)
async def delete(self, key: str) -> bool:
# Skip delete if Redis not available
if not await self.can_cache():
self.logger.debug(f"RedisCache.delete: cache indisponible, suppression ignorée pour {key}")
return False
async def delete_operation():
client = await self.get_redis_client()
return bool(await client.delete(key))
return await self.execute_with_retry(delete_operation)
try:
return await self.execute_with_retry(delete_operation)
except Exception as e:
# Log at debug to avoid polluting logs when deletion fails
self.logger.debug(f"RedisCache.delete: suppression impossible pour {key}: {e}")
return False
async def exists(self, key: str) -> bool:
async def exists_operation():
@ -222,4 +232,4 @@ class RedisCache(CacheBase):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
await self.close()

View file

@ -5,7 +5,7 @@ from urllib.parse import unquote
from fastapi import HTTPException
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.utils.general import season_episode_in_filename
from stream_fusion.utils.general import season_episode_in_filename, get_info_hash_from_magnet
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
@ -53,7 +53,12 @@ class AllDebrid(BaseDebrid):
stream_type = query['type']
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
torrent_id = self.add_magnet_or_torrent(magnet, torrent_download, ip)
if torrent_download and (settings.yggflix_url and settings.yggflix_url in torrent_download):
logger.info("AllDebrid: Using .torrent file for private tracker")
torrent_id = self.add_magnet_or_torrent(magnet, torrent_download, ip)
else:
torrent_id = self.add_magnet_or_torrent(magnet, torrent_download, ip)
logger.info(f"AllDebrid: Torrent ID: {torrent_id}")
if not self.wait_for_ready_status(
@ -81,10 +86,11 @@ class AllDebrid(BaseDebrid):
matching_files.append(file)
if len(matching_files) == 0:
logger.error(f"AllDebrid: No matching files for S{numeric_season:02d}E{numeric_episode:02d} in torrent.")
raise HTTPException(status_code=404, detail=f"No matching files for S{numeric_season:02d}E{numeric_episode:02d} in torrent.")
link = max(matching_files, key=lambda x: x["size"])["link"]
logger.warning(f"AllDebrid: No matching files found for S{numeric_season:02d}E{numeric_episode:02d}")
return None
else:
# Correspondance trouvée via filename parsing
link = max(matching_files, key=lambda x: x["size"])["link"]
else:
logger.error("AllDebrid: Unsupported stream type.")
raise HTTPException(status_code=500, detail="Unsupported stream type.")
@ -110,7 +116,8 @@ class AllDebrid(BaseDebrid):
logger.info("AllDebrid: No hashes to be sent.")
return {"status": "success", "data": {"magnets": []}}
result_magnets = []
# Construire les hashes pour la requête
hashes_to_check = []
for hash_or_magnet in hashes_or_magnets:
try:
# Marquer tous les magnets comme disponibles immédiatement
@ -121,7 +128,6 @@ class AllDebrid(BaseDebrid):
})
except Exception as e:
logger.error(f"AllDebrid: Error processing hash {hash_or_magnet}: {str(e)}")
# Même en cas d'erreur, on marque comme disponible
result_magnets.append({
"hash": hash_or_magnet,
"instant": True,

View file

@ -1,7 +1,9 @@
from collections import deque
import json
import time
import asyncio
import aiohttp
import requests
from stream_fusion.logging_config import logger
@ -9,10 +11,11 @@ from stream_fusion.settings import settings
class BaseDebrid:
def __init__(self, config):
def __init__(self, config, session: aiohttp.ClientSession = None):
self.config = config
self.logger = logger
self.__session = self._create_session()
# Use provided session or create a new one
self.__session = session if session else self._create_session()
# Rate limiters
self.global_limit = 250
@ -24,13 +27,10 @@ class BaseDebrid:
self.torrent_requests = deque()
def _create_session(self):
session = requests.Session()
session = aiohttp.ClientSession()
if settings.proxy_url:
self.logger.info(f"BaseDebrid: Using proxy: {settings.proxy_url}")
session.proxies = {
"http": str(settings.proxy_url),
"https": str(settings.proxy_url),
}
session.connector = aiohttp.TCPConnector(proxy=str(settings.proxy_url))
return session
def _rate_limit(self, requests_queue, limit, period):
@ -52,58 +52,54 @@ class BaseDebrid:
def _torrent_rate_limit(self):
self._rate_limit(self.torrent_requests, self.torrent_limit, self.torrent_period)
def json_response(self, url, method="get", data=None, headers=None, files=None):
self._global_rate_limit()
async def json_response(self, url, method="get", data=None, headers=None, files=None):
self._global_rate_limit() # Assuming these are quick checks
if "torrents" in url:
self._torrent_rate_limit()
self._torrent_rate_limit() # Assuming these are quick checks
max_attempts = 5
for attempt in range(max_attempts):
try:
if method == "get":
response = self.__session.get(url, headers=headers)
elif method == "post":
response = self.__session.post(
url, data=data, headers=headers, files=files
)
elif method == "put":
response = self.__session.put(url, data=data, headers=headers)
elif method == "delete":
response = self.__session.delete(url, headers=headers)
else:
raise ValueError(f"BaseDebrid: Unsupported HTTP method: {method}")
# Use aiohttp session for async requests
async with self.__session.request(
method, url, data=data, headers=headers # Files might need different handling with aiohttp
# If 'files' are used, it typically involves FormData which needs setup
# For simplicity, assuming 'files' is not commonly used or handled correctly elsewhere
) as response:
response.raise_for_status() # Check for HTTP errors (4xx, 5xx)
response.raise_for_status()
try:
return response.json()
except json.JSONDecodeError as json_err:
self.logger.error(f"BaseDebrid: Invalid JSON response: {json_err}")
self.logger.debug(
f"BaseDebrid: Response content: {response.text[:200]}..."
)
if attempt < max_attempts - 1:
wait_time = 2**attempt + 1
self.logger.info(
f"BaseDebrid: Retrying in {wait_time} seconds..."
try:
# Await the json parsing
json_data = await response.json()
return json_data
except aiohttp.ContentTypeError as json_err: # Catch aiohttp's specific error
self.logger.error(f"BaseDebrid: Invalid JSON response: {json_err}")
resp_text = await response.text()
self.logger.debug(
f"BaseDebrid: Response content: {resp_text[:200]}..."
)
time.sleep(wait_time)
else:
return None
if attempt < max_attempts - 1:
wait_time = 2**attempt + 1
self.logger.info(
f"BaseDebrid: Retrying in {wait_time} seconds..."
)
await asyncio.sleep(wait_time) # Use asyncio.sleep
else:
return None
except requests.exceptions.HTTPError as e:
status_code = e.response.status_code
except aiohttp.ClientResponseError as e: # Catch aiohttp HTTP errors
status_code = e.status
if status_code == 429:
wait_time = 2**attempt + 1
self.logger.warning(
f"BaseDebrid: Rate limit exceeded. Attempt {attempt + 1}/{max_attempts}. Waiting for {wait_time} seconds."
)
time.sleep(wait_time)
await asyncio.sleep(wait_time) # Use asyncio.sleep
elif 400 <= status_code < 500:
self.logger.error(
f"BaseDebrid: Client error occurred: {e}. Status code: {status_code}"
)
return None
return None # Stop retrying on client errors (except 429)
elif 500 <= status_code < 600:
self.logger.error(
f"BaseDebrid: Server error occurred: {e}. Status code: {status_code}"
@ -113,32 +109,35 @@ class BaseDebrid:
self.logger.info(
f"BaseDebrid: Retrying in {wait_time} seconds..."
)
time.sleep(wait_time)
await asyncio.sleep(wait_time) # Use asyncio.sleep
else:
return None
return None # Stop after max attempts for server errors
else:
self.logger.error(
f"BaseDebrid: Unexpected HTTP error occurred: {e}. Status code: {status_code}"
)
return None
except requests.exceptions.ConnectionError as e:
except aiohttp.ClientConnectionError as e: # Catch connection errors
self.logger.error(f"BaseDebrid: Connection error occurred: {e}")
if attempt < max_attempts - 1:
wait_time = 2**attempt + 1
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
time.sleep(wait_time)
await asyncio.sleep(wait_time) # Use asyncio.sleep
else:
return None
except requests.exceptions.Timeout as e:
except asyncio.TimeoutError as e: # Catch timeouts (if configured in session)
self.logger.error(f"BaseDebrid: Request timed out: {e}")
if attempt < max_attempts - 1:
wait_time = 2**attempt + 1
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
time.sleep(wait_time)
await asyncio.sleep(wait_time) # Use asyncio.sleep
else:
return None
except requests.exceptions.RequestException as e:
self.logger.error(f"BaseDebrid: An unexpected error occurred: {e}")
except aiohttp.ClientError as e: # Catch other aiohttp client errors
self.logger.error(f"BaseDebrid: An unexpected aiohttp error occurred: {e}")
return None
except Exception as e: # Catch any other unexpected error
self.logger.error(f"BaseDebrid: An unexpected general error occurred: {e}", exc_info=True)
return None
self.logger.error(
@ -146,21 +145,35 @@ class BaseDebrid:
)
return None
def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
async def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
"""Waits for a torrent to be ready by periodically calling check_status_func."""
self.logger.info(f"BaseDebrid: Waiting for {timeout} seconds for caching.")
start_time = time.time()
while time.time() - start_time < timeout:
if check_status_func():
# Assume check_status_func might become async if it involves IO
status_ready = await check_status_func()
if status_ready:
self.logger.info("BaseDebrid: File is ready!")
return True
time.sleep(interval)
await asyncio.sleep(interval)
self.logger.info(f"BaseDebrid: Waiting timed out.")
return False
def download_torrent_file(self, download_url):
response = requests.get(download_url)
response.raise_for_status()
return response.content
async def download_torrent_file(self, download_url):
"""Downloads a torrent file from a URL asynchronously."""
try:
async with self.__session.get(download_url) as response:
response.raise_for_status() # Check for HTTP errors
# Read the content asynchronously
content = await response.read()
return content
except aiohttp.ClientError as e:
self.logger.error(f"BaseDebrid: Failed to download torrent file from {download_url}: {e}")
# Optionally re-raise or return None/empty bytes based on desired error handling
return None
except Exception as e:
self.logger.error(f"BaseDebrid: Unexpected error downloading torrent file: {e}", exc_info=True)
return None
def get_stream_link(self, query, ip=None):
raise NotImplementedError

View file

@ -4,81 +4,158 @@ from stream_fusion.utils.debrid.alldebrid import AllDebrid
from stream_fusion.utils.debrid.realdebrid import RealDebrid
from stream_fusion.utils.debrid.torbox import Torbox
from stream_fusion.utils.debrid.premiumize import Premiumize
from stream_fusion.utils.debrid.stremthrudebrid import StremThruDebrid
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
from fastapi import Request
import aiohttp
def normalize_service_name(service_name):
return service_name.lower().replace('-', '')
def get_all_debrid_services(config):
services = config['service']
debrid_service = []
if not services:
services_config = config.get('service', [])
debrid_services = []
stremthru_enabled = config.get('stremthru_enabled', False)
if not services_config:
logger.error("No service configuration found in the config file.")
return []
for service in services:
if service == "Real-Debrid":
debrid_service.append(RealDebrid(config))
logger.debug("Real-Debrid: service added to be use")
if service == "AllDebrid":
debrid_service.append(AllDebrid(config))
logger.debug("AllDebrid: service added to be use")
if service == "TorBox":
debrid_service.append(Torbox(config))
logger.debug("TorBox: service added to be use")
if service == "Premiumize":
debrid_service.append(Premiumize(config))
logger.debug("Premiumize: service added to be use")
if not debrid_service:
raise HTTPException(status_code=500, detail="Invalid service configuration.")
return debrid_service
if stremthru_enabled:
logger.info("StremThru est activé (config requête). Tentative d'instanciation du client StremThru.")
if config.get('stremthru_url'):
try:
debrid_services.append(StremThruDebrid(config))
logger.info("Client StremThruDebrid instancié avec succès.")
except Exception as e:
logger.error(f"Erreur lors de l'instanciation de StremThruDebrid: {e}")
else:
logger.error("StremThru est activé mais stremthru_url manque dans la config.")
# Instantiate direct Debrid services based on config['service']
logger.debug("Instantiating direct Debrid services based on config['service']")
service_map = {
"RD": (RealDebrid, config.get('RDToken')),
"Real-Debrid": (RealDebrid, config.get('RDToken')),
"AD": (AllDebrid, config.get('ADToken')),
"AllDebrid": (AllDebrid, config.get('ADToken')),
"PM": (Premiumize, config.get('PMToken')),
"Premiumize": (Premiumize, config.get('PMToken')),
"TB": (Torbox, config.get('TBToken')),
"TorBox": (Torbox, config.get('TBToken'))
}
direct_services_to_instantiate = [s for s in services_config if s in service_map]
if not direct_services_to_instantiate and not debrid_services:
logger.error("No valid direct Debrid services found and StremThru not used.")
return []
for service_name in direct_services_to_instantiate:
service_class, token = service_map[service_name]
if token:
try:
debrid_services.append(service_class(config))
logger.info(f"Service {service_name} instantiated successfully.")
except Exception as e:
logger.error(f"Error instantiating {service_name}: {e}")
else:
logger.warning(f"Missing token for {service_name}; service skipped.")
if not debrid_services:
logger.error("Finalement, aucun service Debrid valide n'a pu être instancié.")
return []
unique_services = []
seen_types = set()
for service in debrid_services:
if type(service) not in seen_types:
unique_services.append(service)
seen_types.add(type(service))
logger.info(f"Services Debrid instanciés : {[type(s).__name__ for s in unique_services]}")
return unique_services
def get_download_service(config):
target_service_name = None
if not settings.download_service:
service = config.get('debridDownloader')
if not service:
# Si aucun service n'est spécifié, utiliser le service activé
services = config.get('service', [])
if len(services) == 1:
service = services[0]
logger.info(f"Using active service as download service: {service}")
service_config_name = config.get('debridDownloader')
if not service_config_name:
enabled_services = config.get('service', [])
if len(enabled_services) == 1:
target_service_name = enabled_services[0]
logger.info(f"Utilisation du seul service actif comme service de téléchargement: {target_service_name}")
else:
logger.error("Multiple services enabled. Please select a download service in the web interface.")
logger.error("Plusieurs services activés. Veuillez sélectionner un service de téléchargement.")
raise HTTPException(
status_code=500,
detail="Multiple services enabled. Please select a download service in the web interface."
)
else:
target_service_name = service_config_name
else:
service = settings.download_service
if service == "Real-Debrid":
target_service_name = settings.download_service
if not target_service_name:
logger.error("Impossible de déterminer le service de téléchargement cible.")
raise HTTPException(status_code=500, detail="Impossible de déterminer le service de téléchargement.")
logger.debug(f"Service de téléchargement cible déterminé: {target_service_name}")
# Use StremThru if enabled, otherwise fall back to direct service
if config.get("stremthru_enabled", False):
logger.info(f"StremThru activé. Utilisation de StremThruDebrid pour '{target_service_name}'.")
return StremThruDebrid(config)
logger.info(f"StremThru désactivé. Utilisation du client direct pour '{target_service_name}'.")
if target_service_name == "Real-Debrid":
return RealDebrid(config)
elif service == "AllDebrid":
elif target_service_name == "AllDebrid":
return AllDebrid(config)
elif service == "TorBox":
elif target_service_name == "TorBox":
return Torbox(config)
elif service == "Premiumize":
elif target_service_name == "Premiumize":
return Premiumize(config)
else:
logger.error(f"Invalid download service: {service}")
raise HTTPException(
status_code=500,
detail=f"Invalid download service: {service}. Please select a valid download service in the web interface."
)
logger.error(f"Service de téléchargement invalide spécifié: {target_service_name}")
raise HTTPException(status_code=500, detail=f"Invalid download service: {target_service_name}.")
SERVICE_MAP = {
"RD": "Real-Debrid",
"AD": "AllDebrid",
"TB": "TorBox",
"PM": "Premiumize",
"DL": "DOWNLOAD_SERVICE"
}
def get_debrid_service(config, service):
if not service:
service = settings.download_service
if service == "RD":
return RealDebrid(config)
elif service == "AD":
return AllDebrid(config)
elif service == "TB":
return Torbox(config)
elif service == "PM":
return Premiumize(config)
elif service == "DL":
return get_download_service(config)
def get_debrid_service(config, service_short_code, request: Request):
target_service_name = None
http_session = request.app.state.http_session
if service_short_code == "DL":
logger.debug("get_debrid_service: Délégation à get_download_service pour 'DL'.")
return get_download_service(config)
elif service_short_code in SERVICE_MAP:
target_service_name = SERVICE_MAP[service_short_code]
logger.debug(f"get_debrid_service: Service demandé '{service_short_code}' mappé à '{target_service_name}'.")
else:
logger.error("Invalid service configuration return by stremio in the query.")
raise HTTPException(status_code=500, detail="Invalid service configuration return by stremio.")
logger.warning(f"get_debrid_service: Code de service inconnu '{service_short_code}'.")
raise HTTPException(status_code=400, detail=f"Unknown service code: {service_short_code}")
# Use StremThru proxy for all services if enabled
if config.get('stremthru_enabled', False):
logger.info(f"StremThru activé. Délégation de '{target_service_name}' au client StremThru.")
return StremThruDebrid(config, session=http_session)
# Fallback: direct service instantiation
service_map = {
"real-debrid": RealDebrid,
"alldebrid": AllDebrid,
"torbox": Torbox,
"premiumize": Premiumize
}
normalized = normalize_service_name(target_service_name)
if normalized in service_map:
cls = service_map[normalized]
return cls(config, session=http_session)
else:
logger.error(f"Service Debrid invalide spécifié: {target_service_name}")
raise HTTPException(status_code=400, detail=f"Unsupported debrid service: {target_service_name}")

View file

@ -0,0 +1,337 @@
import re
import time
import asyncio
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urljoin, quote_plus
import aiohttp
import logging
import json
from fastapi import HTTPException
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.settings import settings
logger = logging.getLogger(__name__)
class StremThruDebrid(BaseDebrid):
"""Debrid proxy via StremThru for multiple stores."""
STORE_CODE_TO_NAME = {
'rd': 'realdebrid',
'ad': 'alldebrid',
'pz': 'premiumize',
'tb': 'torbox',
'dl': 'debridlink',
'en': 'easydebrid',
'oc': 'offcloud',
'pp': 'pikpak',
}
STORE_NAME_TO_TOKEN_KEY = {
'realdebrid': 'RDToken',
'alldebrid': 'ADToken',
'premiumize': 'PMToken',
'torbox': 'TBToken',
'debridlink': 'DLToken',
'easydebrid': 'EDToken',
'offcloud': 'OCToken',
'pikpak': 'PKToken',
}
def __init__(self, config: Dict[str, Any], session: aiohttp.ClientSession = None):
super().__init__(config, session=session)
# Determine base URL: try settings first, then config
base = getattr(settings, 'stremthru_base_url', None) or config.get('stremthru_url')
if not base:
logger.warning("StremThruDebrid: URL not specified; proxy calls may fail.")
self.base_url = ''
else:
self.base_url = base.rstrip('/')
logger.info(f"StremThruDebrid initialized with base URL {self.base_url or '<none>'}")
async def _request(
self,
method: str,
path: str,
store_name: str,
params: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
timeout: int = 20,
max_retries: int = 3,
) -> Any:
"""Central async HTTP request to StremThru API."""
token_key = self.STORE_NAME_TO_TOKEN_KEY.get(store_name)
if not token_key:
logger.error(f"_request: unknown store_name '{store_name}'")
raise HTTPException(status_code=400, detail=f"Unknown store: {store_name}")
token = self.config.get(token_key)
url = urljoin(self.base_url + '/', path.lstrip('/'))
headers = {"Accept": "application/json"}
if token:
headers.update({
"X-StremThru-Store-Name": store_name,
"X-StremThru-Store-Authorization": f"Bearer {token}"
})
if json:
headers["Content-Type"] = "application/json"
for attempt in range(max_retries):
try:
async with aiohttp.ClientSession(headers=headers) as session:
resp = await session.request(method, url, params=params, json=json, timeout=timeout)
text = await resp.text()
# Return empty dict for missing torrent (404) on GET magnet info
if resp.status == 404 and method.upper() == 'GET' and '/magnets/' in path:
return {}
# Handle error status
if resp.status >= 400:
# Try parse error payload
try:
err = await resp.json(content_type=None)
code = err.get('error', {}).get('code', resp.status)
msg = err.get('error', {}).get('message', text)
except:
code = resp.status; msg = text
raise aiohttp.ClientResponseError(
status=resp.status, request_info=resp.request_info,
history=(), message=msg
)
# Return raw text for unrestrict links
if 'unrestrict' in path:
return text
# Parse JSON response
try:
return await resp.json(content_type=None)
except Exception:
# Fallback: extract JSON substring
try:
idx = text.find('{')
return json.loads(text[idx:]) if idx >= 0 else {}
except Exception as e2:
logger.warning(f"_request: JSON fallback failed: {e2}; response text: {text[:100]}")
return {}
except Exception as e:
logger.warning(f"Attempt {attempt+1}/{max_retries} failed: {e}")
await asyncio.sleep(1)
raise RuntimeError(f"Failed request {method} {url}")
def extract_hash_from_magnet(self, magnet: str) -> Optional[str]:
m = re.search(r'btih:([A-Fa-f0-9]{40})', magnet)
return m.group(1).lower() if m else None
async def is_already_added(self, magnet: str, store_name: str, ip: Optional[str] = None) -> Optional[str]:
h = self.extract_hash_from_magnet(magnet)
if not h:
return None
# Liste les magnets existants et cherche l'ID associé au hash
try:
res = await self._request('GET', '/v0/store/magnets', store_name)
items = res.get('data', {}).get('items', [])
for item in items:
if item.get('hash') == h:
return item.get('id')
return None
except aiohttp.ClientResponseError as e:
# 404 ou 400 = non ajouté
if e.status in (404, 400):
return None
raise
async def add_magnet(self, magnet: str, store_name: str, ip: Optional[str] = None) -> str:
h = self.extract_hash_from_magnet(magnet)
if not h: raise ValueError("Invalid magnet")
data = {'magnet': magnet}
# Ajoute le magnet via /v0/store/magnets
res = await self._request('POST', '/v0/store/magnets', store_name, json=data)
logger.debug(f"StremThruDebrid.add_magnet raw response: {res}")
# Extrait l'ID retourné (soit en top-level, soit sous 'data')
id_ = None
if isinstance(res, dict):
id_ = res.get('id') or res.get('data', {}).get('id')
return id_ or h
async def get_torrent_info(self, torrent_id: str, store_name: str, ip: Optional[str] = None) -> Optional[Dict[str, Any]]:
try:
# Récupère le statut via /v0/store/magnets/{id}
res = await self._request('GET', f'/v0/store/magnets/{torrent_id}', store_name)
# Déroule le champ 'data'
data = res.get('data') if isinstance(res, dict) else None
return data if data and data.get('hash') else None
except aiohttp.ClientResponseError as e:
if e.status == 404:
return None
raise
async def delete_torrent(self, torrent_id: str, store_name: str, ip: Optional[str] = None) -> bool:
# Supprime le magnet par l'id StremThru
await self._request('DELETE', f'/v0/store/magnets/{torrent_id}', store_name)
return True
async def unrestrict_link(self, link: str, store_name: str, ip: Optional[str] = None) -> Optional[str]:
# Génère un lien direct via /v0/store/link/generate
res = await self._request('POST', '/v0/store/link/generate', store_name, json={'link': link})
# La réponse est dans data.link
return res.get('data', {}).get('link')
async def wait_for_link(self, torrent_id: str, store_name: str, timeout: int = 60, interval: int = 1) -> bool:
start = time.time()
while time.time()-start < timeout:
info = await self.get_torrent_info(torrent_id, store_name)
if info and info.get('status') in ['cached','ready','downloaded','completed']:
return True
await asyncio.sleep(interval)
return False
def _find_appropriate_link(self, files: List[Dict[str, Any]], file_index: Union[str,int], season: Optional[int], episode: Optional[int]) -> Tuple[Optional[str], Optional[int]]:
# Gère file_index None ou invalide
try:
idx = int(file_index) if file_index is not None else -1
except (TypeError, ValueError):
idx = -1
# Si pas de file_index valide, choisir le fichier avec lien et taille maximale
if idx < 0:
valid_files = [f for f in files if f.get('link')]
if valid_files:
largest = max(valid_files, key=lambda x: x.get('size', 0))
return largest.get('link'), largest.get('index')
for f in files:
if f.get('index') == idx:
return f.get('link'), idx
return None, None
async def get_stream_link(self, query: Dict[str, Any], config: Dict[str, Any], ip: Optional[str] = None) -> str:
magnet = query['magnet']
# Si aucun file_index fourni, on prend -1 (pour la vignettes ou premier lien)
raw_idx = query.get('file_index')
idx = raw_idx if raw_idx is not None else -1
code_raw = query.get('store_code')
if not code_raw:
# Infer store_code from config debridDownloader
default = config.get('debridDownloader')
inv = {v: k for k, v in self.STORE_CODE_TO_NAME.items()}
if default:
code_raw = inv.get(default.lower())
if code_raw:
logger.debug(f"get_stream_link: inferred store_code '{code_raw}' from config.debridDownloader")
else:
logger.error("get_stream_link: cannot infer store_code from config.debridDownloader")
raise HTTPException(status_code=400, detail="Missing or invalid store_code")
else:
logger.error("get_stream_link: missing store_code and no debridDownloader in config")
raise HTTPException(status_code=400, detail="Missing store_code")
code = str(code_raw).lower()
name = self.STORE_CODE_TO_NAME.get(code)
if not name:
logger.error(f"get_stream_link: invalid store_code '{code_raw}'")
raise HTTPException(status_code=400, detail=f"Invalid store code: {code_raw}")
# Check if magnet already added
id_ = await self.is_already_added(magnet, name, ip)
info: Optional[Dict[str, Any]] = None
if id_:
h = id_
# Get initial torrent info
info = await self.get_torrent_info(h, name, ip)
else:
# Add magnet and capture raw response to skip extra GET
res = await self._request('POST', '/v0/store/magnets', name, json={'magnet': magnet})
logger.debug(f"StremThruDebrid.add_magnet raw response: {res}")
# Extract ID
h = res.get('id') or res.get('data', {}).get('id') or self.extract_hash_from_magnet(magnet)
# Use returned data if ready
info = res.get('data') if isinstance(res, dict) else None
# Wait for readiness if not already ready
if not info or info.get('status') not in ['cached','ready','downloaded','completed']:
if not await self.wait_for_link(h, name):
raise RuntimeError("Timeout waiting for torrent")
info = await self.get_torrent_info(h, name, ip)
link, fid = self._find_appropriate_link(info.get('files',[]), idx, query.get('season'), query.get('episode'))
if fid is None:
raise RuntimeError("File not found")
return await self.unrestrict_link(link, name, ip)
def _find_appropriate_link(self, files: List[Dict[str, Any]], file_index: Union[str,int], season: Optional[int], episode: Optional[int]) -> Tuple[Optional[str], Optional[int]]:
# Gère file_index None ou invalide
try:
idx = int(file_index) if file_index is not None else -1
except (TypeError, ValueError):
idx = -1
# Si pas de file_index valide, choisir le fichier avec lien et taille maximale
if idx < 0:
valid_files = [f for f in files if f.get('link')]
if valid_files:
largest = max(valid_files, key=lambda x: x.get('size', 0))
return largest.get('link'), largest.get('index')
for f in files:
if f.get('index') == idx:
return f.get('link'), idx
return None, None
async def get_stream_link(self, query: Dict[str, Any], config: Dict[str, Any], ip: Optional[str] = None) -> str:
magnet = query['magnet']
# Si aucun file_index fourni, on prend -1 (pour la vignettes ou premier lien)
raw_idx = query.get('file_index')
idx = raw_idx if raw_idx is not None else -1
code_raw = query.get('store_code')
if not code_raw:
# Infer store_code from config debridDownloader
default = config.get('debridDownloader')
inv = {v: k for k, v in self.STORE_CODE_TO_NAME.items()}
if default:
code_raw = inv.get(default.lower())
if code_raw:
logger.debug(f"get_stream_link: inferred store_code '{code_raw}' from config.debridDownloader")
else:
logger.error("get_stream_link: cannot infer store_code from config.debridDownloader")
raise HTTPException(status_code=400, detail="Missing or invalid store_code")
else:
logger.error("get_stream_link: missing store_code and no debridDownloader in config")
raise HTTPException(status_code=400, detail="Missing store_code")
code = str(code_raw).lower()
name = self.STORE_CODE_TO_NAME.get(code)
if not name:
logger.error(f"get_stream_link: invalid store_code '{code_raw}'")
raise HTTPException(status_code=400, detail=f"Invalid store code: {code_raw}")
# Check if magnet already added
id_ = await self.is_already_added(magnet, name, ip)
info: Optional[Dict[str, Any]] = None
if id_:
h = id_
# Get initial torrent info
info = await self.get_torrent_info(h, name, ip)
else:
# Add magnet and capture raw response to skip extra GET
res = await self._request('POST', '/v0/store/magnets', name, json={'magnet': magnet})
logger.debug(f"StremThruDebrid.add_magnet raw response: {res}")
# Extract ID
h = res.get('id') or res.get('data', {}).get('id') or self.extract_hash_from_magnet(magnet)
# Use returned data if ready
info = res.get('data') if isinstance(res, dict) else None
# Wait for readiness if not already ready
if not info or info.get('status') not in ['cached','ready','downloaded','completed']:
if not await self.wait_for_link(h, name):
raise RuntimeError("Timeout waiting for torrent")
info = await self.get_torrent_info(h, name, ip)
link, fid = self._find_appropriate_link(info.get('files',[]), idx, query.get('season'), query.get('episode'))
if fid is None:
raise RuntimeError("File not found")
return await self.unrestrict_link(link, name, ip)
async def get_cached_files_async(self, torrent_items: List[Any], ip: Optional[str] = None, sid: Optional[str] = None) -> Tuple[Dict[str,List[Dict]], str]:
return await self.get_cached_files(torrent_items, ip, sid)
async def get_cached_files(self, torrent_items: List[Any], ip: Optional[str] = None, sid: Optional[str] = None) -> Tuple[Dict[str,List[Dict]], str]:
hashes = [i.info_hash for i in torrent_items if getattr(i,'info_hash',None)]
if not hashes: return {}, ''
magnets = [f"magnet:?xt=urn:btih:{h}" for h in hashes]
params={'magnet':','.join(magnets)}
if sid:
params['sid'] = sid
# Select a store with available token, fallback to first
store_name = next((sn for sn, tk in self.STORE_NAME_TO_TOKEN_KEY.items() if self.config.get(tk)), list(self.STORE_NAME_TO_TOKEN_KEY.keys())[0])
# Use global check endpoint; store_name passed via header
res = await self._request('GET', '/v0/store/magnets/check', store_name, params=params)
items = res.get('data',{}).get('items',[])
out={h:[] for h in hashes}
for it in items:
h = it.get('hash'); fs = it.get('files', [])
for f in fs:
# Use 'title' key to represent file name for consistency
out[h].append({'file_index': f.get('index'), 'title': f.get('name'), 'size': f.get('size')})
return out, store_name

View file

@ -1,6 +1,7 @@
from itertools import islice
import uuid
import tenacity
import re
from urllib.parse import unquote
from fastapi import HTTPException
@ -105,11 +106,7 @@ class Torbox(BaseDebrid):
if existing_torrent:
logger.info(f"Torbox: Found existing torrent with ID: {existing_torrent['id']}")
torrent_info = existing_torrent
if not torrent_info or "id" not in torrent_info:
logger.error("Torbox: Failed to add or find torrent.")
return None
torrent_id = torrent_info["id"]
torrent_id = existing_torrent["id"]
else:
# Add the magnet or torrent file
torrent_info = self.add_magnet_or_torrent(magnet, torrent_download)
@ -125,8 +122,14 @@ class Torbox(BaseDebrid):
logger.warning("Torbox: Torrent not ready, caching in progress.")
return settings.no_cache_video_url
# Always get fresh torrent info with complete file list
fresh_torrent_info = self.get_torrent_info(torrent_id)
if not fresh_torrent_info or "data" not in fresh_torrent_info:
logger.error("Torbox: Failed to get torrent info.")
return settings.no_cache_video_url
# Select the appropriate file
file_id = self._select_file(torrent_info, stream_type, file_index, season, episode)
file_id = self._select_file(fresh_torrent_info["data"], stream_type, file_index, season, episode)
if file_id == None:
logger.error("Torbox: No matching file found.")
@ -225,21 +228,64 @@ class Torbox(BaseDebrid):
return largest_file["id"]
elif stream_type == "series":
# Always use intelligent selection for series (ignore file_index)
if file_index is not None:
logger.info(f"Torbox: Selected file index {file_index} for series")
return file_index
logger.debug(f"Torbox: Ignoring file_index {file_index} for series, using intelligent selection")
try:
numeric_season = int(season.replace("S", ""))
numeric_episode = int(episode.replace("E", ""))
except (ValueError, TypeError):
logger.error(f"Torbox: Invalid season/episode format: {season}/{episode}")
return None
matching_files = [
file for file in files
if season_episode_in_filename(file["short_name"], season, episode) and is_video_file(file["short_name"])
matching_files = []
logger.debug(f"Torbox: Searching for S{numeric_season:02d}E{numeric_episode:02d} among {len(files)} files")
# Pre-compile patterns for better performance
episode_patterns = [
rf"[Ss]{numeric_season:02d}[Ee]{numeric_episode:02d}", # S01E01
rf"[Ss]{numeric_season}[Ee]{numeric_episode:02d}", # S1E01
rf"{numeric_season:02d}x{numeric_episode:02d}", # 01x01
rf"{numeric_season}x{numeric_episode:02d}", # 1x01
rf"[Ss]eason.{numeric_season:02d}.*[Ee]{numeric_episode:02d}", # Season 01 Episode 01
rf"[Ss]eason.{numeric_season}.*[Ee]{numeric_episode:02d}", # Season 1 Episode 01
rf"[Ss]{numeric_season:02d}.*[Ee]pisode.{numeric_episode:02d}", # S01 Episode 01
rf"[Ss]{numeric_season}.*[Ee]pisode.{numeric_episode:02d}", # S1 Episode 01
]
if matching_files:
largest_matching_file = max(matching_files, key=lambda x: x["size"])
logger.info(f"Torbox: Selected largest matching file (ID: {largest_matching_file['id']}, Name: {largest_matching_file['name']}, Size: {largest_matching_file['size']}) for series")
return largest_matching_file["id"]
else:
logger.warning(f"Torbox: No matching files found for S{season}E{episode}")
logger.error(f"Torbox: Failed to select appropriate file for {stream_type}")
return None
for file in files:
if not is_video_file(file["short_name"]):
continue
filename = file["short_name"]
logger.debug(f"Torbox: Checking file: {filename}")
# Test RTN parser first
if season_episode_in_filename(filename, numeric_season, numeric_episode):
logger.debug(f"Torbox: ✓ RTN MATCH: {filename}")
matching_files.append(file)
else:
# Immediate fallback with improved patterns
match_found = False
for pattern in episode_patterns:
if re.search(pattern, filename, re.IGNORECASE):
logger.debug(f"Torbox: ✓ REGEX MATCH with pattern '{pattern}': {filename}")
matching_files.append(file)
match_found = True
break
if not match_found:
logger.debug(f"Torbox: ✗ NO MATCH: {filename}")
if len(matching_files) == 0:
logger.warning(f"Torbox: No matching files found for S{numeric_season:02d}E{numeric_episode:02d}")
return None
largest_matching_file = max(matching_files, key=lambda x: x["size"])
logger.info(f"Torbox: Selected {largest_matching_file['short_name']} (ID: {largest_matching_file['id']}) from {len(matching_files)} matches")
if len(matching_files) > 1:
logger.debug(f"Torbox: Other matches: {[f['short_name'] for f in matching_files if f != largest_matching_file]}")
return largest_matching_file["id"]

View file

@ -4,6 +4,7 @@ from typing import List
from RTN import title_match
from stream_fusion.utils.filter.language_filter import LanguageFilter
from stream_fusion.utils.filter.language_priority_filter import LanguagePriorityFilter
from stream_fusion.utils.filter.max_size_filter import MaxSizeFilter
from stream_fusion.utils.filter.quality_exclusion_filter import QualityExclusionFilter
from stream_fusion.utils.filter.title_exclusion_filter import TitleExclusionFilter
@ -22,16 +23,30 @@ def sort_quality(item: TorrentItem):
return priority, item.parsed_data.resolution is None
def get_indexer_priority_for_sort(indexer):
"""Fonction pour obtenir la priorité de l'indexer lors du tri"""
indexer_priority = {
"Yggtorrent": 1, # "Yggtorrent - API"
"DMM": 2, # "DMM - API"
"Public": 3, # "Public - Cache"
"Sharewood": 4, # "Sharewood - API"
"Jackett": 5, # Jackett indexers
}
indexer_name = indexer.split(' ')[0] if indexer and ' ' in indexer else indexer
priority = indexer_priority.get(indexer_name, 999)
logger.trace(f"Filters: Indexer '{indexer}' -> extracted '{indexer_name}' -> priority {priority}")
return priority
def items_sort(items, config):
logger.info(f"Filters: Sorting items by method: {config['sort']}")
logger.info(f"Filters: Sorting items by method: {config['sort']} (YggFlix priority at equal quality)")
if config["sort"] == "quality":
sorted_items = sorted(items, key=sort_quality)
sorted_items = sorted(items, key=lambda x: (sort_quality(x), get_indexer_priority_for_sort(x.indexer)))
elif config["sort"] == "sizeasc":
sorted_items = sorted(items, key=lambda x: int(x.size))
sorted_items = sorted(items, key=lambda x: (int(x.size), get_indexer_priority_for_sort(x.indexer)))
elif config["sort"] == "sizedesc":
sorted_items = sorted(items, key=lambda x: int(x.size), reverse=True)
sorted_items = sorted(items, key=lambda x: (-int(x.size), get_indexer_priority_for_sort(x.indexer)))
elif config["sort"] == "qualitythensize":
sorted_items = sorted(items, key=lambda x: (sort_quality(x), -int(x.size)))
sorted_items = sorted(items, key=lambda x: (sort_quality(x), -int(x.size), get_indexer_priority_for_sort(x.indexer)))
else:
logger.warning(
f"Filters: Unrecognized sort method: {config['sort']}. No sorting applied."
@ -39,7 +54,7 @@ def items_sort(items, config):
sorted_items = items
logger.success(
f"Filters: Sorting complete. Number of sorted items: {len(sorted_items)}"
f"Filters: Sorting complete - Quality first, YggFlix priority at equal quality. Number of sorted items: {len(sorted_items)}"
)
return sorted_items
@ -199,6 +214,9 @@ def filter_items(items, media, config):
"exclusion": QualityExclusionFilter(config),
# "resultsPerQuality": ResultsPerQualityFilter(config),
}
# Filtre de priorité de langue à appliquer en dernier
language_priority_filter = LanguagePriorityFilter(config)
logger.info(f"Filters: Initial item count: {len(items)}")
@ -232,6 +250,34 @@ def filter_items(items, media, config):
f"Filters: Error while applying {filter_name} filter", exc_info=e
)
# Appliquer le filtre de priorité de langue en dernier pour trier les résultats
try:
logger.info(f"Filters: Applying language priority filter")
items = language_priority_filter(items)
logger.success(f"Filters: Items sorted by language priority")
# Tri secondaire par qualité au sein de chaque groupe linguistique
# Regrouper les torrents par priorité de langue
language_groups = {}
for item in items:
priority = getattr(item, 'language_priority', 999)
if priority not in language_groups:
language_groups[priority] = []
language_groups[priority].append(item)
# Trier chaque groupe par qualité et taille selon la méthode de tri configurée
sorted_items = []
for priority in sorted(language_groups.keys()):
group_items = language_groups[priority]
# Appliquer le même tri que celui configuré globalement
sorted_group = items_sort(group_items, config)
sorted_items.extend(sorted_group)
items = sorted_items
logger.success(f"Filters: Items sorted by language priority and then by quality")
except Exception as e:
logger.error(f"Filters: Error while applying language priority filter", exc_info=e)
logger.success(f"Filters: Filtering complete. Final item count: {len(items)}")
return items
@ -255,8 +301,14 @@ def merge_items(
def add_to_merged(item: TorrentItem):
key = (item.raw_title, item.size)
if key not in merged_dict or item.seeders > merged_dict[key].seeders:
if key not in merged_dict:
merged_dict[key] = item
else:
existing_priority = get_indexer_priority_for_sort(merged_dict[key].indexer)
new_priority = get_indexer_priority_for_sort(item.indexer)
if new_priority < existing_priority or (new_priority == existing_priority and item.seeders > merged_dict[key].seeders):
merged_dict[key] = item
for item in cache_items:
add_to_merged(item)

View file

@ -29,7 +29,9 @@ class TorrentItem:
self.trackers = [] # Trackers of the torrent
self.file_index = None # Index of the file inside of the torrent - it may be updated durring __process_torrent() and update_availability(). If the index is None and torrent is not None, it means that the series episode is not inside of the torrent.
self.full_index = None # Case where we cannot call RD to get the full index. Else None
self.availability = False # If it's instantly available on the debrid service
# Initialize availability as an empty string to match get_unaviable_hashes check
self.availability = "" # Stores service codes (e.g., 'AD', 'RD') or remains empty until checked
self.parsed_data: ParsedData = parsed_data # Ranked result
@ -41,6 +43,7 @@ class TorrentItem:
"season": media.season if isinstance(media, Series) else None,
"episode": media.episode if isinstance(media, Series) else None,
"torrent_download": quote(self.torrent_download) if self.torrent_download is not None else None,
# Utiliser la chaîne de disponibilité complète comme code de service
"service": self.availability if self.availability else "DL",
"privacy": self.privacy if self.privacy else "private",
}

View file

@ -36,7 +36,19 @@ class TorrentService:
try:
cached_item = await self.torrent_dao.get_torrent_item_by_id(unique_id)
if cached_item:
return cached_item.to_torrent_item()
torrent_item = cached_item.to_torrent_item()
# Vérifier si c'est un torrent YggFlix mal mis en cache (sans torrent_download)
if (indexer == "Yggtorrent - API" and
(not torrent_item.torrent_download or
not settings.yggflix_url or
settings.yggflix_url not in torrent_item.torrent_download)):
logger.info(f"TorrentService: YggFlix torrent {raw_title} mal mis en cache, suppression pour retraitement")
await self.torrent_dao.delete_torrent_item(unique_id)
return None
return torrent_item
return None
except Exception as e:
self.logger.error(f"Error getting cached torrent: {e}")
@ -96,6 +108,7 @@ class TorrentService:
def __process_ygg_api_url(self, result: TorrentItem):
if not self.config["yggflix"]:
logger.error("Yggflix is not enabled in the config. Skipping processing of Yggflix URL.")
return result
try:
response = self.__session.get(result.link, timeout=10)
time.sleep(0.1) # Add a delay of 0.1 seconds between requests faire usage for small VPS
@ -107,7 +120,11 @@ class TorrentService:
return result
if response.status_code == 200:
return self.__process_torrent(result, response.content)
# Pour YggFlix, on garde le lien .torrent original pour les trackers privés
processed_result = self.__process_torrent(result, response.content)
# Conserver le lien .torrent pour AllDebrid
processed_result.torrent_download = result.link
return processed_result
elif response.status_code == 422:
self.logger.info(f"Not aviable torrent on yggflix: {result.file_name}")
else:
@ -137,12 +154,38 @@ class TorrentService:
return result
def __process_torrent(self, result: TorrentItem, torrent_file):
metadata = bencode.bdecode(torrent_file)
try:
metadata = bencode.bdecode(torrent_file)
except Exception as e:
try:
from bencodepy import Decoder
decoder = Decoder(encoding='latin-1')
metadata = decoder.decode(torrent_file)
except Exception as inner_e:
logger.error(f"Impossible de décoder le fichier torrent: {str(e)} puis {str(inner_e)}")
result.torrent_download = result.link
result.trackers = []
result.info_hash = ""
result.magnet = ""
return result
result.torrent_download = result.link
result.trackers = self.__get_trackers_from_torrent(metadata)
result.info_hash = self.__convert_torrent_to_hash(metadata["info"])
result.magnet = self.__build_magnet(result.info_hash, metadata["info"]["name"], result.trackers)
# Conserver le lien .torrent original si ce n'est pas déjà défini
if not result.torrent_download:
result.torrent_download = result.link
try:
result.trackers = self.__get_trackers_from_torrent(metadata)
result.info_hash = self.__convert_torrent_to_hash(metadata["info"])
# Pour tous les torrents, construire le magnet complet normalement
# La distinction AllDebrid vs autres services se fait au niveau debrid
result.magnet = self.__build_magnet(result.info_hash, metadata["info"]["name"], result.trackers)
except Exception as e:
logger.error(f"Erreur lors du traitement des métadonnées du torrent: {str(e)}")
result.trackers = []
result.info_hash = ""
result.magnet = ""
if "files" not in metadata["info"]:
result.file_index = 1

View file

@ -26,14 +26,11 @@ class TorrentSmartContainer:
self.__media = media
def get_unaviable_hashes(self):
hashes = []
for hash, item in self.__itemsDict.items():
if item.availability is False:
hashes.append(hash)
self.logger.debug(
f"TorrentSmartContainer: Retrieved {len(hashes)} hashes to process"
)
return hashes
return [item.info_hash for item in self.get_items() if item.availability == ""]
def get_unavailable_magnets(self):
"""Retourne les liens magnet pour les items qui n'ont pas encore de disponibilité marquée."""
return [item.magnet for item in self.get_items() if item.availability == "" and item.magnet]
def get_items(self):
items = list(self.__itemsDict.values())
@ -261,21 +258,22 @@ class TorrentSmartContainer:
for data in response["data"]["magnets"]:
torrent_item: TorrentItem = self.__itemsDict[data["hash"]]
# Set availability to AD immediately for all files
torrent_item.availability = "AD"
# Process files if they exist
# Only mark AD availability if we have files from AllDebrid
if "files" in data and data["files"]:
files = []
self._explore_folders_alldebrid(
data["files"], files, 1, torrent_item.type, media
)
if files: # If we found matching files
if files:
self._update_file_details(torrent_item, files, debrid="AD")
else:
self.logger.debug(
f"No matching AD files for hash {data['hash']}; skipping availability update."
)
else:
# If no files data, still mark as available
self.logger.debug(f"No files data for hash {data['hash']}, but marking as available")
torrent_item.availability = "AD"
self.logger.debug(
f"No files data for hash {data['hash']}; skipping AD availability update."
)
self.logger.info(
"TorrentSmartContainer: AllDebrid availability update completed"
@ -410,9 +408,101 @@ class TorrentSmartContainer:
)
self.logger.info(
"TorrentSmartContainer: Premiumize availability update completed"
f"TorrentSmartContainer: Premiumize availability update completed. {len([item for item in torrent_items if item.availability == 'PM'])}/{len(torrent_items)} items marked as instant."
)
def update_availability_stremthru(self, cached_files, store_name, media):
"""
Met à jour la disponibilité des items basée sur les fichiers retournés par StremThru (via get_cached_files).
'cached_files' est maintenant un dictionnaire {info_hash: [file_dict, ...], ...}
'store_name' est le nom du store interne StremThru (ex: 'alldebrid', 'realdebrid').
"""
if not cached_files or not isinstance(cached_files, dict):
self.logger.debug(f"update_availability_stremthru: No cached files provided or not a dict: {type(cached_files)}")
return
# --- MODIFICATION: Calculer le nombre total de fichiers pour le log ---
total_files_count = sum(len(files) for files in cached_files.values())
self.logger.info(f"TorrentSmartContainer: Updating availability from Stremthru cached files dict for store '{store_name}' ({len(cached_files)} hashes, {total_files_count} files total)")
# --- MODIFICATION: Revenir à la logique précédente pour générer le code ---
# Déterminer le code de disponibilité basé sur le store StremThru
# store_availability_code = config.get_stremthru_availability_code(store_name) # Utilise une méthode de config
# Générer le code court pour la disponibilité (comme avant)
availability_code = store_name[:2].upper() if store_name else "ST" # Utiliser 'ST' si store_name est None/vide
if store_name == "alldebrid": availability_code = "AD"
elif store_name == "easydebrid": availability_code = "ED"
elif store_name == "realdebrid": availability_code = "RD"
elif store_name == "premiumize": availability_code = "PM"
elif store_name == "debridlink": availability_code = "DL"
elif store_name == "pikpak": availability_code = "PK"
elif store_name == "offcloud": availability_code = "OC"
elif store_name == "torbox": availability_code = "TB"
# Ajouter d'autres si besoin
# IMPORTANT: Préfixer le code pour indiquer la gestion par Stremthru
stremthru_availability_code = f"ST:{availability_code}"
self.logger.info(f"Using availability code '{stremthru_availability_code}' for store '{store_name}' from Stremthru cached files.")
# Log the raw data received from StremThru
self.logger.debug(f"TorrentSmartContainer: Received raw cached_files data from Stremthru for store '{store_name}': {cached_files}")
updated_hashes = set() # Garder trace des hashes uniques mis à jour
updated_hashes_count = 0
skipped_non_matching = 0
# --- MODIFICATION: Itérer sur les valeurs (listes de fichiers), puis les fichiers ---
for info_hash_key, files_list in cached_files.items(): # Itérer sur les paires clé(hash)/valeur(liste de fichiers)
item = self.__itemsDict.get(info_hash_key) # Récupérer l'item TorrentItem correspondant au hash
if not item:
# Ce cas ne devrait pas arriver si get_cached_files a bien utilisé les hashes du container
self.logger.warning(f"update_availability_stremthru: Infohash {info_hash_key} from StremThru response not found in container.")
continue
# Marquer que ce hash a été mis à jour (au moins un fichier trouvé)
if info_hash_key not in updated_hashes:
updated_hashes_count += 1
updated_hashes.add(info_hash_key)
# Traiter chaque fichier trouvé pour ce hash
for file_info in files_list: # Maintenant, file_info est bien un dictionnaire
# Validation basique de file_info (pourrait être renforcée)
if not isinstance(file_info, dict):
self.logger.warning(f"update_availability_stremthru: Expected dict for file_info, got {type(file_info)} for hash {info_hash_key}. Skipping this file.")
continue
# info_hash = file_info.get("info_hash") # On a déjà info_hash_key
file_index = file_info.get("file_index")
file_title = file_info.get("title")
file_size = file_info.get("size")
# Skip invalid or unknown indices (e.g., None or -1)
if file_index is None or file_index < 0:
self.logger.debug(
f"update_availability_stremthru: Skipping file '{file_title}' for hash {info_hash_key} due to invalid file_index {file_index}."
)
skipped_non_matching += 1
continue
# Update first valid file_info using base method (use availability_code without ST: prefix)
if item:
self._update_file_details(
item,
[{'file_index': file_index, 'title': file_title, 'size': file_size}],
debrid=f"ST:{availability_code}"
)
self.logger.debug(
f"StremThru: Updated file details for hash {info_hash_key}, file_index {file_index}, title '{file_title}', availability '{availability_code}'"
)
# Only use the first matching file
break
# Count skips due to missing file_index
if file_index is None:
skipped_non_matching += 1
# Log final
self.logger.info(f"TorrentSmartContainer: Availability update from Stremthru completed. {updated_hashes_count} items potentially updated. Skipped {skipped_non_matching} files due to missing index.")
def _update_file_details(self, torrent_item, files, debrid: str = "??"):
if not files:
self.logger.debug(

View file

@ -3,6 +3,7 @@ import json
from urllib.parse import unquote
import redis.asyncio as redis
import asyncio
import time
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from redis.exceptions import LockError
from fastapi.responses import RedirectResponse, StreamingResponse
@ -21,6 +22,7 @@ from stream_fusion.utils.debrid.get_debrid_service import (
)
from stream_fusion.utils.debrid.realdebrid import RealDebrid
from stream_fusion.utils.debrid.torbox import Torbox
from stream_fusion.utils.debrid.stremthrudebrid import StremThruDebrid
from stream_fusion.utils.parse_config import parse_config
from stream_fusion.utils.string_encoding import decodeb64
from stream_fusion.utils.security import check_api_key
@ -91,37 +93,46 @@ class ProxyStreamer:
logger.debug("Playback: Streaming connection closed")
# class ProxyStreamer:
# def __init__(self, request: Request, url: str, headers: dict):
# self.request = request
# self.url = url
# self.headers = headers
# self.response = None
# async def stream_content(self):
# async with self.request.app.state.http_session.get(
# self.url, headers=self.headers
# ) as self.response:
# async for chunk in self.response.content.iter_any():
# yield chunk
# async def close(self):
# if self.response:
# await self.response.release()
# logger.debug("Streaming connection closed")
async def handle_download(
query: dict, config: dict, ip: str, redis_cache: RedisCache
) -> str:
api_key = config.get("apiKey")
cache_key = f"download:{api_key}:{json.dumps(query)}_{ip}"
cache_key_params = {k: query[k] for k in sorted(query.keys())}
cache_key = f"download:{api_key}:{json.dumps(cache_key_params)}_{ip}"
# Check if a download is already in progress
if await redis_cache.get(cache_key) == DOWNLOAD_IN_PROGRESS_FLAG:
logger.info("Playback: Download already in progress")
return settings.no_cache_video_url
# Determine the actual download service (could be StremThru even for 'DL' request)
download_service = get_download_service(config)
# If StremThru is the configured download handler
if isinstance(download_service, StremThruDebrid):
logger.info("Playback (handle_download): StremThru service detected as download handler. Attempting direct stream link retrieval.")
# Ensure store_code present for StremThruDebrid
if not query.get('store_code'):
default_service = config.get('debridDownloader')
inv = {v: k for k, v in StremThruDebrid.STORE_CODE_TO_NAME.items()}
code = inv.get(default_service.lower()) if default_service else None
if code:
logger.debug(f"Playback: inferred store_code '{code}' for StremThruDebrid based on debridDownloader '{default_service}'")
query['store_code'] = code
else:
logger.warning(f"Playback: cannot infer store_code for StremThruDebrid from config.debridDownloader '{default_service}'")
try:
direct_link = await download_service.get_stream_link(query, config, ip)
if direct_link:
logger.success(f"Playback (handle_download): StremThru provided direct link: {direct_link[:60]}...")
# Return the direct link immediately, skip Redis flag and status page
return direct_link
else:
logger.warning("Playback (handle_download): StremThru did not provide an immediate link. Proceeding with background process indication.")
except Exception as e:
logger.error(f"Playback (handle_download): Error getting StremThru link: {e}. Proceeding with background process indication.")
# Fall through to the standard download status logic
# Mark the start of the download
await redis_cache.set(
cache_key, DOWNLOAD_IN_PROGRESS_FLAG, expiration=600 # 10 minute expiration
@ -163,39 +174,44 @@ async def handle_download(
status_code=500,
detail="Failed to add magnet or torrent to TorBox",
)
elif isinstance(debrid_service, StremThruDebrid):
# StremThru handles caching/downloading via its get_stream_link/add_magnet logic.
# No explicit background caching call needed here.
logger.info("Playback: StremThru service detected. Skipping explicit background caching call.")
pass # Nothing to do here for StremThru in this specific block
else:
magnet = query["magnet"]
torrent_download = (
unquote(query["torrent_download"])
if query["torrent_download"] is not None
else None
)
try:
# Start background caching
if debrid_service.start_background_caching(magnet, query):
logger.success(
f"Playback: Started background caching for magnet: {magnet[:50]}"
)
# Check if the service supports background caching before attempting to call it
if hasattr(debrid_service, 'start_background_caching'):
if debrid_service.start_background_caching(query):
logger.success(
f"Playback: Started background caching for magnet: {query['magnet'][:50]}"
)
else:
# If the method exists but returns False, it failed
raise HTTPException(
status_code=500,
detail="Failed to start background caching (service returned false)"
)
else:
raise HTTPException(
status_code=500,
detail="Failed to start background caching"
)
# Log a warning if the service doesn't support this method
logger.warning(f"Playback: Service {type(debrid_service).__name__} does not support start_background_caching. Skipping.")
# Since caching isn't started/needed, clear the progress flag immediately.
await redis_cache.delete(cache_key)
except Exception as e:
logger.error(f"Error starting background caching: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to start background caching: {str(e)}"
)
# Ensure the flag is deleted on any exception during the caching attempt
await redis_cache.delete(cache_key)
return settings.no_cache_video_url
except Exception as e:
# Ensure the flag is deleted on any other exception within handle_download
await redis_cache.delete(cache_key)
logger.error(f"Playback: Error handling download: {str(e)}", exc_info=True)
raise e
async def get_stream_link(
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache, cache_user_identifier: str
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache, cache_user_identifier: str, request: Request
) -> str:
logger.debug(f"Playback: Getting stream link for query: {decoded_query}, IP: {ip}")
cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
@ -213,8 +229,10 @@ async def get_stream_link(
if service == "DL":
link = await handle_download(query, config, ip, redis_cache)
elif service:
debrid_service = get_debrid_service(config, service)
link = debrid_service.get_stream_link(query, config, ip)
debrid_service = get_debrid_service(config, service, request)
logger.info(f"Playback: Attempting get_stream_link for service '{type(debrid_service).__name__}' and query {decoded_query}...")
link = await debrid_service.get_stream_link(query, config, ip)
logger.info(f"Playback: Service '{type(debrid_service).__name__}' returned link: {link}")
else:
logger.error("Playback: Service not found in query")
raise HTTPException(status_code=500, detail="Service not found in query")
@ -228,6 +246,154 @@ async def get_stream_link(
return link
# Nouvelle route pour le playback via Stremthru
@router.get("/stremthru/{store_code}/{config}/{query}")
@rate_limiter(limit=settings.playback_limit_requests, seconds=settings.playback_limit_seconds)
async def get_stremthru_playback(
store_code: str,
config: str,
query: str,
request: Request,
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
apikey_dao: APIKeyDAO = Depends(),
):
start_time = time.time()
ip = request.client.host
logger.info(f"Playback GET Stremthru/{store_code}: Request received from {ip}")
try:
config_dict = parse_config(config)
api_key = config_dict.get("apiKey")
cache_user_identifier = api_key if api_key else ip
# Valider la clé API si elle existe
if api_key:
try:
await check_api_key(api_key, apikey_dao)
logger.info(f"Playback GET Stremthru/{store_code}: Valid API key provided by {ip}")
except HTTPException as e:
logger.warning(f"Playback GET Stremthru/{store_code}: Invalid API key provided by {ip}. Error: {e.detail}")
raise e
else:
logger.info(f"Playback GET Stremthru/{store_code}: No API key provided by {ip}. Proceeding without validation.")
if not query:
raise HTTPException(status_code=400, detail="Query required.")
decoded_query = decodeb64(query)
query_dict = json.loads(decoded_query)
# Récupérer l'instance StremThruDebrid
# Assurez-vous que StremThruDebrid peut être initialisé correctement avec config_dict
# et qu'il utilise le bon store_name basé sur la config ou store_code?
# Pour l'instant, supposons que l'instance StremThru est le gestionnaire principal.
try:
stremthru_service = StremThruDebrid(config_dict)
# TODO: Vérifier si StremThruDebrid utilise bien le store_code implicitement
# ou s'il faut le passer/configurer spécifiquement.
# Il lit `store_name` et `store_auth` de config_dict, donc la config URL doit les contenir.
except Exception as e:
logger.error(f"Playback GET Stremthru/{store_code}: Failed to initialize StremThruDebrid: {e}")
raise HTTPException(status_code=500, detail="Failed to initialize Stremthru service")
logger.info(f"Playback GET Stremthru/{store_code}: Attempting to get stream link via Stremthru service.")
logger.debug(f"Playback GET Stremthru/{store_code}: Query details: {query_dict}")
# Add store_code to query_dict before passing it
query_dict['store_code'] = store_code
# Appeler get_stream_link qui gère tout le processus (add, wait, unrestrict)
# Elle prend query (dict), config (dict), ip (str)
stream_link = await stremthru_service.get_stream_link(query=query_dict, config=config_dict, ip=ip)
if not stream_link:
logger.error(f"Playback GET Stremthru/{store_code}: Failed to get stream link from Stremthru service.")
raise HTTPException(status_code=502, detail="Failed to get stream link from Stremthru service")
logger.info(f"Playback GET Stremthru/{store_code}: Stream link obtained: {stream_link[:60]}...")
# Rediriger ou Proxy
if settings.proxied_link:
logger.info(f"Playback GET Stremthru/{store_code}: Proxying stream from {stream_link[:60]}...")
headers = {key: value for key, value in request.headers.items() if key.lower() in ["range", "accept"]}
streamer = ProxyStreamer(request, stream_link, headers)
return StreamingResponse(
streamer.stream_content(),
headers={"Accept-Ranges": "bytes", "Content-Range": request.headers.get("range", "bytes 0-")},
background=BackgroundTask(streamer.close),
)
else:
logger.info(f"Playback GET Stremthru/{store_code}: Redirecting to {stream_link[:60]}...")
return RedirectResponse(url=stream_link, status_code=302)
except HTTPException: # Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Playback GET Stremthru/{store_code}: Unexpected error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Internal server error during Stremthru playback: {e}")
finally:
end_time = time.time()
logger.info(f"Playback GET Stremthru/{store_code}: Request processing time: {end_time - start_time:.2f} seconds")
# Nouvelle route HEAD pour Stremthru
@router.head("/stremthru/{store_code}/{config}/{query}")
async def head_stremthru_playback(
store_code: str,
config: str,
query: str,
request: Request,
apikey_dao: APIKeyDAO = Depends(), # Ajouter dépendances nécessaires
):
ip = request.client.host
logger.info(f"Playback HEAD Stremthru/{store_code}: Request received from {ip}")
try:
config_dict = parse_config(config)
api_key = config_dict.get("apiKey")
# Valider la clé API si elle existe
if api_key:
try:
await check_api_key(api_key, apikey_dao)
logger.info(f"Playback HEAD Stremthru/{store_code}: Valid API key provided by {ip}")
except HTTPException as e:
logger.warning(f"Playback HEAD Stremthru/{store_code}: Invalid API key provided by {ip}. Error: {e.detail}")
raise e
else:
logger.info(f"Playback HEAD Stremthru/{store_code}: No API key provided by {ip}. Proceeding without validation.")
if not query:
raise HTTPException(status_code=400, detail="Query required.")
# La logique HEAD est simple: renvoyer OK avec les bons en-têtes.
# On ne vérifie PAS le lien distant ici pour Stremthru pour l'instant.
# Le client (Stremio) fera la requête GET si le HEAD réussit.
headers = {
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Content-Length": "0" # Indiquer une taille 0 car on ne connaît pas la vraie taille
}
logger.info(f"Playback HEAD Stremthru/{store_code}: Returning 200 OK for HEAD request.")
return Response(status_code=status.HTTP_200_OK, headers=headers)
except HTTPException: # Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Playback HEAD Stremthru/{store_code}: Unexpected error: {e}", exc_info=True)
return Response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=ErrorResponse(
detail="An error occurred while processing the HEAD request."
).model_dump_json(),
media_type="application/json",
)
@router.get("/{config}/{query}", responses={500: {"model": ErrorResponse}})
@rate_limiter(limit=20, seconds=60, redis=redis_session)
async def get_playback(
@ -277,8 +443,8 @@ async def get_playback(
try:
if await lock.acquire(blocking=False):
logger.debug("Playback: Lock acquired, getting stream link")
# Pass cache_user_identifier to get_stream_link
link = await get_stream_link(decoded_query, config, ip, redis_cache, cache_user_identifier)
# Pass cache_user_identifier and request to get_stream_link
link = await get_stream_link(decoded_query, config, ip, redis_cache, cache_user_identifier, request)
else:
logger.debug("Playback: Lock not acquired, waiting for cached link")
# Use cache_user_identifier for cache key lookup

View file

@ -2,6 +2,7 @@ import json
import queue
import re
import threading
import json
from typing import List
from RTN import ParsedData
@ -71,10 +72,30 @@ def parse_to_debrid_stream(
results: queue.Queue,
media: Media,
):
if torrent_item.availability:
name = f"{INSTANTLY_AVAILABLE}|{torrent_item.availability}-|{INSTANTLY_AVAILABLE}"
# Determine if AllDebrid cache is available (direct or via StremThru)
avail = torrent_item.availability
is_ad_cached = False
if isinstance(avail, dict):
# check codes like 'ST:AD' in dict values
for code in avail.values():
if 'AD' in code:
is_ad_cached = True
break
elif isinstance(avail, str) and 'AD' in avail:
is_ad_cached = True
# Determine display name: show only service code when cached
if is_ad_cached:
# extract code from availability value
if isinstance(avail, dict):
first = next(iter(avail.values()), None)
code = first if isinstance(first, str) else None
else:
code = avail if isinstance(avail, str) else None
code = code or "AD"
name = f"{INSTANTLY_AVAILABLE}{code}+"
else:
name = f"{DOWNLOAD_REQUIRED}|DL-|{DOWNLOAD_REQUIRED}"
# show file title when not cached
name = torrent_item.file_name or torrent_item.raw_title
parsed_data: ParsedData = torrent_item.parsed_data
@ -120,21 +141,19 @@ def parse_to_debrid_stream(
json.dumps(torrent_item.to_debrid_stream_query(media))
).replace("=", "%3D")
results.put(
{
"name": name,
"description": title,
"url": f"{host}/playback/{configb64}/{queryb64}",
"behaviorHints": {
"bingeGroup": f"stremio-jackett-{torrent_item.info_hash}",
"filename": (
torrent_item.file_name
if torrent_item.file_name is not None
else torrent_item.raw_title
),
},
results.put({
"name": name,
"description": title,
"url": f"{host}/playback/{configb64}/{queryb64}",
"behaviorHints": {
"bingeGroup": f"stremio-jackett-{torrent_item.info_hash}",
"filename": (
torrent_item.file_name
if torrent_item.file_name is not None
else torrent_item.raw_title
),
}
)
})
if torrenting and torrent_item.privacy == "public":
name = f"{DIRECT_TORRENT}\n{parsed_data.quality}\n"

View file

@ -1,7 +1,7 @@
import hashlib
import time
from fastapi import APIRouter, Depends, HTTPException, Request
import asyncio
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
@ -37,12 +37,14 @@ from stream_fusion.utils.torrent.torrent_smart_container import TorrentSmartCont
from stream_fusion.utils.zilean.zilean_result import ZileanResult
from stream_fusion.utils.zilean.zilean_service import ZileanService
from stream_fusion.settings import settings
from stream_fusion.utils.debrid.stremthrudebrid import StremThruDebrid
from concurrent.futures import ThreadPoolExecutor
router = APIRouter()
@router.get("/{config}/stream/{stream_type}/{stream_id}", response_model=SearchResponse)
@router.get("/{config}/stream/{stream_type}/{stream_id:path}", response_model=SearchResponse)
async def get_results(
request: Request,
config: str,
@ -288,25 +290,72 @@ async def get_results(
search_results = ResultsPerQualityFilter(config).filter(raw_search_results)
logger.info(f"Search: Filtered search results per quality: {len(search_results)}")
def stream_processing(search_results, media, config):
async def stream_processing(search_results, media, config) -> list[dict]:
torrent_smart_container = TorrentSmartContainer(search_results, media)
if config["debrid"]:
for debrid in debrid_services:
hashes = torrent_smart_container.get_unaviable_hashes()
ip = request.client.host
result = debrid.get_availability_bulk(hashes, ip)
tasks = []
for service in debrid_services:
service_name = service.__class__.__name__
logger.debug(f"Processing service: {service_name}")
# Check if StremThruDebrid is the service instance
if isinstance(service, StremThruDebrid):
logger.debug(f"Creating StremThru task for {service_name}.get_cached_files_async")
# StremThru uses get_cached_files_async with all items
task = asyncio.create_task(service.get_cached_files_async(torrent_smart_container.get_items()))
tasks.append((service_name, task, service))
else:
# Direct services use get_availability_bulk with unavailable hashes
hashes_to_check = torrent_smart_container.get_unaviable_hashes()
if not hashes_to_check:
logger.debug(f"No unavailable hashes to check with direct service {service_name}. Skipping.")
continue
logger.debug(f"Creating Direct Debrid task for {service_name}.get_availability_bulk with {len(hashes_to_check)} hashes")
# Pass unavailable hashes and media context
task = asyncio.create_task(service.get_availability_bulk(hashes_to_check, media))
tasks.append((service_name, task, service))
# Gather results from all tasks (StremThru or Direct)
if not tasks:
logger.debug("No availability check tasks were created.")
results = []
else:
results = await asyncio.gather(*[task for _, task, _ in tasks])
logger.debug(f"Gathered {len(results)} results from availability checks.")
for i, (service_name, _, service_instance) in enumerate(tasks):
result = results[i]
logger.debug(f"Processing result from {service_name} (instance type: {type(service_instance).__name__})")
if isinstance(service_instance, StremThruDebrid):
if result:
torrent_smart_container.update_availability(
result, type(debrid), media
)
logger.info(
f"Search: Checked availability for {len(result.items())} items with {type(debrid).__name__}"
)
result_dict, used_store_name = result
if result_dict:
logger.debug(f"Updating availability from StremThru store '{used_store_name}'")
torrent_smart_container.update_availability_stremthru(cached_files=result_dict, store_name=used_store_name, media=media)
else:
logger.info(f"No cached files found by StremThru for store '{used_store_name}'.")
else:
logger.warning(
"Search: No availability results found in debrid service"
)
logger.warning(f"Invalid or empty result received from StremThru {service_name}.")
elif isinstance(result, dict):
logger.debug(f"Updating availability from direct service {service_name}")
torrent_smart_container.update_availability(debrid_response=result, debrid_type=type(service_instance), media=media)
else:
logger.warning(f"Received unexpected result type ({type(result)}) from {service_name}. Skipping update.")
logger.debug("--- Entering Availability Check Block ---")
logger.debug("--- Container Items Availability Check (After Updates) ---")
container_items = torrent_smart_container.get_items()
if container_items:
for i, item in enumerate(container_items[:5]):
filename_log = item.file_name[:60] if item.file_name else "[No Filename]"
logger.debug(f"Item {i} Hash: {item.info_hash}, Filename: {filename_log}..., Availability: {item.availability}")
if len(container_items) > 5:
logger.debug(f"... (logged first 5 out of {len(container_items)} items)")
else:
logger.debug("Container is empty after updates.")
logger.debug("--- End Availability Check ---")
if config["cache"]:
logger.info("Search: Caching public container items")
@ -322,7 +371,7 @@ async def get_results(
return stream_list
stream_list = stream_processing(search_results, media, config)
stream_list = await stream_processing(search_results, media, config)
streams = [Stream(**stream) for stream in stream_list]
await redis_cache.set(stream_cache_key(media), streams, expiration=1200)
total_time = time.time() - start