Compare commits

...

13 commits
host ... master

Author SHA1 Message Date
Telkaoss
59a1386d5b update 2026-03-02 17:18:40 +01:00
Telkaoss
a834fea508 update 2026-03-02 17:12:43 +01:00
Telkaoss
817be7fd7d
Add workflow_dispatch to docker-release.yml 2026-03-02 12:02:40 +01:00
Telkaoss
4fc4a5cacc update 2026-03-02 11:32:28 +01:00
Telkaoss
563a4129a8
Add files via upload 2026-02-25 11:56:56 +01:00
Telkaoss
bd66a772f7
Add files via upload 2026-02-25 10:16:48 +01:00
Telkaoss
40bdc41334
Add infoHash to playback URL generation 2026-01-07 11:51:51 +01:00
Telkaoss
15029df552
Add infoHash to playback URL generation 2026-01-07 11:43:23 +01:00
Telkaoss
02b3794ce6
Add files via upload 2025-11-07 02:02:29 +01:00
Telkaoss
465ea3ce22
Add flatten_files function for AllDebrid API response
Introduced a new function to flatten nested file structures from AllDebrid API responses. Updated the logic to handle flattened files for both movies and series.
2025-10-30 00:08:37 +01:00
Telkaoss
26c2cee770
Update magnet file retrieval and error handling
Refactor magnet handling to use v4 API and improve error handling.
2025-10-29 22:15:32 +01:00
Telkaoss
12c9c78368
update index 2025-10-16 09:27:07 +02:00
Telkaoss
b562f09859
Update index 2025-10-16 09:24:40 +02:00
64 changed files with 4078 additions and 2353 deletions

View file

@ -3,6 +3,11 @@ name: Build and Push Docker image to GitHub Packages on Release
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Tag to build and push as latest (e.g. v2.3.5)'
required: true
jobs:
build:
@ -11,6 +16,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.tag || github.event.release.tag_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@ -35,7 +42,7 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/telkaoss/stream-fusion:latest
ghcr.io/telkaoss/stream-fusion:${{ github.event.release.tag_name }}
ghcr.io/telkaoss/stream-fusion:${{ github.event.inputs.tag || github.event.release.tag_name }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.description=Docker image for Stream Fusion

View file

@ -6,9 +6,6 @@ from .services.postgresql.db_init import init_db
app = FastAPI()
# Initialiser la base de données au démarrage de l'application
@app.on_event("startup")
async def startup_event():
print("🔄 Initialisation de la base de données...")
await init_db()
print("✅ Application prête à recevoir des requêtes")

View file

@ -116,6 +116,7 @@ FRENCH_PATTERNS = {
"VQ": r"\b(?:VOQ|VQ)\b",
"VOSTFR": r"\b(?:VOSTFR|SUBFRENCH)\b",
"FRENCH": r"\b(?:FRENCH|FR)\b",
"MULTI": r"\b(?:MULTI)\b",
}
class CustomException(Exception):

View file

@ -1,8 +1,8 @@
from typing import List, Optional
from fastapi import Depends
from sqlalchemy import select, func
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from stream_fusion.services.postgresql.dependencies import get_db_session
from stream_fusion.services.postgresql.models.torrentitem_model import TorrentItemModel
@ -10,7 +10,6 @@ from stream_fusion.logging_config import logger
from stream_fusion.utils.torrent.torrent_item import TorrentItem
class TorrentItemDAO:
"""Class for accessing TorrentItem table."""
def __init__(self, session: AsyncSession = Depends(get_db_session)) -> None:
self.session = session
@ -23,11 +22,11 @@ class TorrentItemDAO:
self.session.add(new_item)
await self.session.flush()
await self.session.refresh(new_item)
logger.debug(f"TorrentItemDAO: Created new TorrentItem: {new_item.id}")
return new_item
except Exception as e:
logger.error(f"TorrentItemDAO: Error creating TorrentItem: {str(e)}")
if "duplicate key value violates unique constraint" not in str(e):
logger.error(f"TorrentItemDAO: Error creating TorrentItem: {str(e)}")
async def get_all_torrent_items(self, limit: int, offset: int) -> List[TorrentItemModel]:
async with self.session.begin():
@ -67,14 +66,18 @@ class TorrentItemDAO:
logger.warning(f"TorrentItemDAO: TorrentItem not found for update: {item_id}")
return None
# Update fields
for key, value in torrent_item.__dict__.items():
if key == 'size' and value is not None:
try:
value = int(value)
except (ValueError, TypeError):
logger.warning(f"TorrentItemDAO: Invalid size value '{value}' for item {item_id}, skipping")
continue
setattr(db_item, key, value)
db_item.updated_at = int(datetime.now(timezone.utc).timestamp())
await self.session.flush()
await self.session.refresh(db_item)
logger.debug(f"TorrentItemDAO: Updated TorrentItem: {item_id}")
return db_item
except Exception as e:
@ -110,7 +113,7 @@ class TorrentItemDAO:
except Exception as e:
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by info_hash {info_hash}: {str(e)}")
return None
async def get_torrent_items_by_indexer(self, indexer: str) -> List[TorrentItemModel]:
async with self.session.begin():
try:
@ -122,7 +125,7 @@ class TorrentItemDAO:
except Exception as e:
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by indexer {indexer}: {str(e)}")
return None
async def is_torrent_item_cached(self, item_id: str) -> bool:
async with self.session.begin():
try:
@ -158,4 +161,320 @@ class TorrentItemDAO:
return items
except Exception as e:
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by availability {available}: {str(e)}")
return None
return None
async def search_by_info_hash(self, info_hash: str) -> Optional[TorrentItemModel]:
async with self.session.begin():
try:
query = select(TorrentItemModel).where(TorrentItemModel.info_hash == info_hash).limit(1)
result = await self.session.execute(query)
item = result.scalar_one_or_none()
if item:
logger.debug(f"TorrentItemDAO: Found torrent with info_hash: {info_hash}")
return item
except Exception as e:
logger.error(f"TorrentItemDAO: Error searching by info_hash {info_hash}: {str(e)}")
return None
async def search_by_tmdb_id(self, tmdb_id: int) -> List[TorrentItemModel]:
async with self.session.begin():
try:
query = select(TorrentItemModel).where(TorrentItemModel.tmdb_id == tmdb_id)
result = await self.session.execute(query)
items = result.scalars().all()
logger.debug(f"TorrentItemDAO: Found {len(items)} torrents for TMDB ID: {tmdb_id}")
return items
except Exception as e:
logger.error(f"TorrentItemDAO: Error searching by TMDB ID {tmdb_id}: {str(e)}")
return []
async def update_torrent_file_path(self, torrent_id: str, file_path: str) -> bool:
async with self.session.begin():
try:
query = select(TorrentItemModel).where(TorrentItemModel.id == torrent_id)
result = await self.session.execute(query)
db_item = result.scalar_one_or_none()
if not db_item:
logger.warning(f"TorrentItemDAO: TorrentItem not found for file path update: {torrent_id}")
return False
db_item.torrent_file_path = file_path
db_item.updated_at = int(datetime.now(timezone.utc).timestamp())
await self.session.flush()
await self.session.refresh(db_item)
logger.debug(f"TorrentItemDAO: Updated torrent_file_path for {torrent_id}: {file_path}")
return True
except Exception as e:
logger.error(f"TorrentItemDAO: Error updating torrent_file_path for {torrent_id}: {str(e)}")
return False
async def update_torrent_file_path_and_tmdb_id(self, torrent_id: str, file_path: str, tmdb_id: Optional[int]) -> bool:
async with self.session.begin():
try:
query = select(TorrentItemModel).where(TorrentItemModel.id == torrent_id)
result = await self.session.execute(query)
db_item = result.scalar_one_or_none()
if not db_item:
logger.warning(f"TorrentItemDAO: TorrentItem not found for update: {torrent_id}")
return False
db_item.torrent_file_path = file_path
if tmdb_id:
db_item.tmdb_id = tmdb_id
db_item.updated_at = int(datetime.now(timezone.utc).timestamp())
await self.session.flush()
await self.session.refresh(db_item)
logger.debug(f"TorrentItemDAO: Updated torrent_file_path ({file_path}) and TMDB ID ({tmdb_id}) for {torrent_id}")
return True
except Exception as e:
logger.error(f"TorrentItemDAO: Error updating torrent_file_path and tmdb_id for {torrent_id}: {str(e)}")
return False
async def update_tmdb_id_by_raw_title(self, raw_title: str, tmdb_id: int) -> int:
async with self.session.begin():
try:
stmt = (
update(TorrentItemModel)
.where(TorrentItemModel.raw_title == raw_title)
.where(TorrentItemModel.tmdb_id.is_(None))
.values(
tmdb_id=tmdb_id,
updated_at=int(datetime.now(timezone.utc).timestamp())
)
)
result = await self.session.execute(stmt)
await self.session.flush()
row_count = result.rowcount
logger.debug(f"TorrentItemDAO: Updated {row_count} torrents with raw_title '{raw_title}' to tmdb_id {tmdb_id}")
return row_count
except Exception as e:
logger.error(f"TorrentItemDAO: Error updating tmdb_id for raw_title '{raw_title}': {str(e)}")
return 0
async def get_latest_tmdb_ids(self, item_type: str, limit: int = 50) -> List[int]:
async with self.session.begin():
try:
query = select(
TorrentItemModel.tmdb_id,
func.min(TorrentItemModel.created_at).label('first_seen')
).where(
TorrentItemModel.type == item_type,
TorrentItemModel.tmdb_id.isnot(None),
TorrentItemModel.indexer == "Yggtorrent - API",
TorrentItemModel.languages.any('fr')
).group_by(
TorrentItemModel.tmdb_id
).order_by(
func.min(TorrentItemModel.created_at).desc()
).limit(limit)
result = await self.session.execute(query)
rows = result.fetchall()
tmdb_ids = [row.tmdb_id for row in rows]
logger.debug(f"TorrentItemDAO: Retrieved {len(tmdb_ids)} latest TMDB IDs (FR/MULTI) for {item_type}")
return tmdb_ids
except Exception as e:
logger.error(f"TorrentItemDAO: Error getting latest TMDB IDs for {item_type}: {str(e)}")
return []
async def get_recently_added_tmdb_ids(self, item_type: str, limit: int = 50) -> List[int]:
async with self.session.begin():
try:
query = select(
TorrentItemModel.tmdb_id,
func.max(TorrentItemModel.created_at).label('last_added')
).where(
TorrentItemModel.type == item_type,
TorrentItemModel.tmdb_id.isnot(None),
TorrentItemModel.indexer == "Yggtorrent - API"
).group_by(
TorrentItemModel.tmdb_id
).order_by(
func.max(TorrentItemModel.created_at).desc()
).limit(limit)
result = await self.session.execute(query)
rows = result.fetchall()
tmdb_ids = [row.tmdb_id for row in rows]
logger.debug(f"TorrentItemDAO: Retrieved {len(tmdb_ids)} recently added TMDB IDs for {item_type}")
return tmdb_ids
except Exception as e:
logger.error(f"TorrentItemDAO: Error getting recently added TMDB IDs for {item_type}: {str(e)}")
return []
async def get_series_with_new_episodes(self, recent_days: int = 7, limit: int = 50) -> List[int]:
async with self.session.begin():
try:
from sqlalchemy.sql.expression import literal_column
cutoff_timestamp = int((datetime.now(timezone.utc) - timedelta(days=recent_days)).timestamp())
subquery = select(
TorrentItemModel.tmdb_id,
literal_column("parsed_data->>'seasons'").label('season_json'),
literal_column("parsed_data->>'episodes'").label('episode_json'),
func.min(TorrentItemModel.created_at).label('first_seen')
).where(
TorrentItemModel.type == 'series',
TorrentItemModel.tmdb_id.isnot(None),
TorrentItemModel.parsed_data.isnot(None)
).group_by(
TorrentItemModel.tmdb_id,
literal_column("parsed_data->>'seasons'"),
literal_column("parsed_data->>'episodes'")
).having(
func.min(TorrentItemModel.created_at) >= cutoff_timestamp
).subquery()
query = select(
subquery.c.tmdb_id,
func.max(subquery.c.first_seen).label('latest_new_episode')
).group_by(
subquery.c.tmdb_id
).order_by(
func.max(subquery.c.first_seen).desc()
).limit(limit)
result = await self.session.execute(query)
rows = result.fetchall()
tmdb_ids = [row.tmdb_id for row in rows]
logger.debug(f"TorrentItemDAO: Retrieved {len(tmdb_ids)} series with new episodes (last {recent_days} days)")
return tmdb_ids
except Exception as e:
logger.error(f"TorrentItemDAO: Error getting series with new episodes: {str(e)}")
return []
async def filter_existing_tmdb_ids(self, tmdb_ids: List[int], item_type: str, recent_days: Optional[int] = None, sort_by_added: bool = False, return_episode_info: bool = False):
if not tmdb_ids:
return []
async with self.session.begin():
try:
conditions = [
TorrentItemModel.tmdb_id.in_(tmdb_ids),
TorrentItemModel.type == item_type,
TorrentItemModel.indexer == "Yggtorrent - API",
]
if recent_days is not None:
cutoff_timestamp = int((datetime.now(timezone.utc) - timedelta(days=recent_days)).timestamp())
if item_type == "series":
from sqlalchemy.sql.expression import literal_column
subquery = select(
TorrentItemModel.tmdb_id,
literal_column("parsed_data->>'seasons'").label('season_json'),
literal_column("parsed_data->>'episodes'").label('episode_json'),
func.min(TorrentItemModel.created_at).label('first_seen')
).where(
TorrentItemModel.tmdb_id.in_(tmdb_ids),
TorrentItemModel.type == item_type,
TorrentItemModel.parsed_data.isnot(None)
).group_by(
TorrentItemModel.tmdb_id,
literal_column("parsed_data->>'seasons'"),
literal_column("parsed_data->>'episodes'")
).having(
func.min(TorrentItemModel.created_at) >= cutoff_timestamp
).subquery()
query = select(subquery.c.tmdb_id.distinct())
else:
conditions.append(TorrentItemModel.created_at >= cutoff_timestamp)
query = select(TorrentItemModel.tmdb_id.distinct()).where(*conditions)
else:
from sqlalchemy import or_
conditions.append(or_(TorrentItemModel.languages.any('fr'), TorrentItemModel.languages.any('multi')))
if sort_by_added:
vostfr_conditions = conditions + [
~TorrentItemModel.raw_title.ilike('%VOSTFR%'),
~TorrentItemModel.raw_title.ilike('%FANSUB%'),
~TorrentItemModel.raw_title.ilike('%SUBFRENCH%'),
]
if item_type == "series":
from sqlalchemy.sql.expression import literal_column
subquery = select(
TorrentItemModel.tmdb_id,
literal_column("parsed_data->>'seasons'").label('season_json'),
literal_column("parsed_data->>'episodes'").label('episode_json'),
func.min(TorrentItemModel.created_at).label('first_seen')
).where(
*vostfr_conditions,
TorrentItemModel.parsed_data.isnot(None)
).group_by(
TorrentItemModel.tmdb_id,
literal_column("parsed_data->>'seasons'"),
literal_column("parsed_data->>'episodes'")
).subquery()
cutoff_30d = int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp())
if return_episode_info:
query = select(
subquery.c.tmdb_id,
subquery.c.season_json,
subquery.c.episode_json,
subquery.c.first_seen
).where(
subquery.c.first_seen >= cutoff_30d
).distinct(
subquery.c.tmdb_id
).order_by(
subquery.c.tmdb_id,
subquery.c.first_seen.desc()
)
else:
query = select(
subquery.c.tmdb_id,
func.max(subquery.c.first_seen).label('latest_new_episode')
).group_by(
subquery.c.tmdb_id
).having(
func.max(subquery.c.first_seen) >= cutoff_30d
).order_by(
func.max(subquery.c.first_seen).desc()
)
else:
query = select(
TorrentItemModel.tmdb_id,
func.min(TorrentItemModel.created_at).label('first_seen')
).where(*vostfr_conditions).group_by(
TorrentItemModel.tmdb_id
).order_by(
func.min(TorrentItemModel.created_at).desc()
)
else:
query = select(TorrentItemModel.tmdb_id.distinct()).where(*conditions)
result = await self.session.execute(query)
rows = result.fetchall()
if return_episode_info and item_type == "series" and sort_by_added:
episode_data = []
for row in rows:
episode_data.append({
'tmdb_id': row[0],
'season': row[1],
'episode': row[2],
'first_seen': row[3]
})
episode_data.sort(key=lambda x: x['first_seen'], reverse=True)
logger.debug(f"TorrentItemDAO: Filtered {len(tmdb_ids)} TMDB IDs to {len(episode_data)} with episode info for {item_type}")
return episode_data
elif sort_by_added and recent_days is None:
filtered_ids = [row[0] for row in rows]
else:
existing_ids = {row[0] for row in rows}
filtered_ids = [tid for tid in tmdb_ids if tid in existing_ids]
recent_info = f" (recent {recent_days}d, by episode)" if recent_days and item_type == "series" else (f" (recent {recent_days}d)" if recent_days else "")
sort_info = ", sorted by added date" if sort_by_added else ""
logger.debug(f"TorrentItemDAO: Filtered {len(tmdb_ids)} TMDB IDs to {len(filtered_ids)} existing (FR/MULTI{recent_info}{sort_info}) for {item_type}")
return filtered_ids
except Exception as e:
logger.error(f"TorrentItemDAO: Error filtering TMDB IDs for {item_type}: {str(e)}")
return []

View file

@ -1,34 +1,12 @@
"""Initialisation de la base de données."""
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from .base import Base
from .models.apikey_model import APIKeyModel
from stream_fusion.settings import settings
async def init_db():
"""Initialise la base de données et crée les tables si elles n'existent pas."""
engine = create_async_engine(str(settings.pg_url))
async with engine.begin() as conn:
# Créer les tables si elles n'existent pas
await conn.run_sync(Base.metadata.create_all)
# Vérifier si la colonne proxied_links existe
result = await conn.execute(
text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='api_keys' AND column_name='proxied_links'""")
)
if not result.fetchone():
# Ajouter la colonne si elle n'existe pas
await conn.execute(
text("ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS proxied_links BOOLEAN DEFAULT FALSE")
)
print("✅ Colonne 'proxied_links' ajoutée à la table 'api_keys'")
await engine.dispose()
print("✅ Base de données initialisée avec succès")

View file

@ -8,7 +8,6 @@ Create Date: 2025-01-11 00:00:00.000000
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_proxied_links'
down_revision = 'df288f2cf1fa'
branch_labels = None
@ -16,10 +15,8 @@ depends_on = None
def upgrade() -> None:
# Add proxied_links column to api_keys table
op.add_column('api_keys', sa.Column('proxied_links', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
# Remove proxied_links column from api_keys table
op.drop_column('api_keys', 'proxied_links')

View file

@ -0,0 +1,24 @@
"""add tmdb_id column to torrent_items
Revision ID: add_tmdb_id
Revises: add_proxied_links
Create Date: 2025-11-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'add_tmdb_id'
down_revision = 'add_proxied_links'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('torrent_items', sa.Column('tmdb_id', sa.Integer(), nullable=True))
op.create_index(op.f('ix_torrent_items_tmdb_id'), 'torrent_items', ['tmdb_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_torrent_items_tmdb_id'), table_name='torrent_items')
op.drop_column('torrent_items', 'tmdb_id')

View file

@ -25,7 +25,8 @@ class TorrentItemModel(Base):
indexer: Mapped[str] = mapped_column(String, nullable=False)
privacy: Mapped[str] = mapped_column(String, nullable=False)
type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
tmdb_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
file_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
files: Mapped[Optional[List[dict]]] = mapped_column(JSON, nullable=True) # Kept as JSON
torrent_download: Mapped[Optional[str]] = mapped_column(String, nullable=True)
@ -40,8 +41,8 @@ class TorrentItemModel(Base):
updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
@staticmethod
def generate_unique_id(raw_title: str, size: int, indexer: str = "cached") -> str:
unique_string = f"{raw_title}_{size}_{indexer}"
def generate_unique_id(raw_title: str, size: int, indexer: str = "cached", info_hash: str = "") -> str:
unique_string = f"{raw_title}_{size}_{indexer}_{info_hash}"
full_hash = hashlib.sha256(unique_string.encode()).hexdigest()
return full_hash[:16]
@ -50,7 +51,8 @@ class TorrentItemModel(Base):
kwargs['id'] = self.generate_unique_id(
kwargs.get('raw_title', ''),
self._parse_size(kwargs.get('size', 0)),
kwargs.get('indexer', 'cached')
kwargs.get('indexer', 'cached'),
kwargs.get('info_hash', '')
)
super().__init__(**kwargs)
current_time = int(datetime.now().timestamp())
@ -59,6 +61,24 @@ class TorrentItemModel(Base):
if 'updated_at' not in kwargs:
self.updated_at = current_time
@staticmethod
def _remove_ed2k_from_files(value):
"""Remove ed2k bytes field from files/full_index for JSON serialization"""
if not value:
return value
if isinstance(value, list):
result = []
for item in value:
if isinstance(item, dict):
# Create a new dict without ed2k key
new_item = {k: v for k, v in item.items() if k != 'ed2k'}
result.append(new_item)
else:
result.append(item)
return result
return value
@classmethod
def from_torrent_item(cls, torrent_item: TorrentItem):
model_dict = {}
@ -67,13 +87,32 @@ class TorrentItemModel(Base):
if attr == 'size':
model_dict[attr] = cls._parse_size(value)
elif attr in ['files', 'full_index']:
model_dict[attr] = cls._parse_json(value)
# Remove ed2k bytes before JSON serialization
cleaned_value = cls._remove_ed2k_from_files(value)
model_dict[attr] = cls._parse_json(cleaned_value)
elif attr == 'parsed_data':
model_dict[attr] = value.model_dump() if value else None
if value:
try:
# Try to convert to dict if it's a Pydantic model
if hasattr(value, 'model_dump'):
model_dict[attr] = value.model_dump()
elif isinstance(value, dict):
model_dict[attr] = value
else:
# Convert to dict representation
model_dict[attr] = vars(value) if hasattr(value, '__dict__') else str(value)
except Exception as e:
logger.warning(f"Could not serialize parsed_data: {e}")
model_dict[attr] = None
else:
model_dict[attr] = None
elif attr == "availability":
model_dict[attr] = False
elif attr == "seeders":
model_dict[attr] = int(value) if value else 0
elif attr in ["torrent_file", "torrent_file_path"]:
# Skip both torrent_file and torrent_file_path - not storing these in DB
pass
else:
model_dict[attr] = value
@ -81,13 +120,30 @@ class TorrentItemModel(Base):
def to_torrent_item(self):
from RTN.models import ParsedData
from RTN import parse
from stream_fusion.utils.torrent.torrent_item import TorrentItem
torrent_item_dict = {}
raw_title = None
for attr, value in self.__dict__.items():
if attr not in ['_sa_instance_state', 'created_at', 'updated_at']:
if attr == 'parsed_data':
torrent_item_dict[attr] = ParsedData(**value) if value else None
if attr == 'raw_title':
raw_title = value
torrent_item_dict[attr] = value
elif attr == 'parsed_data':
# Handle parsed_data conversion with validation
if value is None:
torrent_item_dict[attr] = None
elif isinstance(value, dict):
try:
torrent_item_dict[attr] = ParsedData(**value)
except Exception:
# If parsing dict fails, reparse from raw_title
torrent_item_dict[attr] = parse(raw_title) if raw_title else None
else:
# If it's a string or other type, reparse from raw_title
torrent_item_dict[attr] = parse(raw_title) if raw_title else None
else:
torrent_item_dict[attr] = value
@ -113,4 +169,4 @@ class TorrentItemModel(Base):
return json.loads(value)
except json.JSONDecodeError:
return None
return value
return value

View file

@ -45,9 +45,9 @@ class NoCacheVideoLanguages(str, enum.Enum):
def get_default_worker_count():
"""
Calculate the default number of workers based on CPU cores.
Returns the number of CPU cores multiplied by 2, with a minimum of 2 and a maximum of 6.
Returns the number of CPU cores multiplied by 2, with a minimum of 2 and a maximum of 10.
"""
return min(max(multiprocessing.cpu_count() * 2, 2), 6)
return min(max(multiprocessing.cpu_count() * 2, 2), 8)
def check_env_variable(var_name):
@ -135,12 +135,14 @@ class Settings(BaseSettings):
pg_pass: str = "streamfusion" # "stremio"
pg_base: str = "streamfusion"
pg_echo: bool = False
pg_pool_size: int = 100
pg_max_overflow: int = 50
# REDIS
redis_host: str = "redis"
redis_port: int = 6379
redis_db: int = 5
redis_expiration: int = 604800
redis_expiration: int = 604800 # 7 jours
redis_password: str | None = None
# TMDB
@ -190,6 +192,17 @@ class Settings(BaseSettings):
sharewood_passkey: str | None = None
sharewood_unique_account: bool = check_env_variable("SHAREWOOD_PASSKEY")
# C411 TORZNAB
c411_url: str = "https://c411.org"
c411_api_key: str | None = None # Env: C411_API_KEY — Torznab access key
c411_passkey: str | None = None # Env: C411_PASSKEY — full tracker announce URL
c411_unique_account: bool = check_env_variable("C411_API_KEY")
# TORR9 TORZNAB
torr9_url: str = "https://api.torr9.xyz"
torr9_api_key: str | None = None # Env: TORR9_API_KEY
torr9_unique_account: bool = check_env_variable("TORR9_API_KEY")
# PUBLIC_CACHE
public_cache_url: str = "https://stremio-jackett-cacher.elfhosted.com/"
@ -283,6 +296,20 @@ class Settings(BaseSettings):
"""
return self.no_cache_video_language.value
@property
def banned_video_url(self) -> str:
"""
Get the URL for the banned video when torrent is unavailable for legal reasons.
"""
return "https://raw.githubusercontent.com/Telkaoss/stream-fusion/refs/heads/master/stream_fusion/static/videos/banned_error.mp4"
@property
def slots_full_video_url(self) -> str:
"""
Get the URL for the slots full video when TorBox slots are full.
"""
return "https://raw.githubusercontent.com/Telkaoss/stream-fusion/refs/heads/master/stream_fusion/static/videos/slots_full.mp4"
try:
settings = Settings()

View file

@ -2,10 +2,9 @@ const sorts = ['quality', 'sizedesc', 'sizeasc', 'qualitythensize'];
const qualityExclusions = ['2160p', '1080p', '720p', '480p', 'rips', 'cam', 'hevc', 'unknown'];
const languages = ['en', 'fr', 'multi', 'vfq'];
// Débrideurs implémentés nativement dans Stream Fusion
const implementedDebrids = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix'];
// Débrideurs qui nécessitent StremThru pour fonctionner
const unimplementedDebrids = ['debrid_dl', 'debrid_ed', 'debrid_oc', 'debrid_pk'];
document.addEventListener('DOMContentLoaded', function () {
@ -14,6 +13,17 @@ document.addEventListener('DOMContentLoaded', function () {
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) {
@ -188,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);
@ -222,7 +232,7 @@ function resetADAuthButton() {
}
function handleUniqueAccounts() {
const accounts = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix'];
const accounts = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix', 'c411', 'torr9'];
accounts.forEach(account => {
const checkbox = document.getElementById(account);
@ -508,13 +518,16 @@ function updateProviderFields() {
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 tbChecked = document.getElementById('debrid_tb')?.checked || document.getElementById('debrid_tb')?.disabled;
const torboxChecked = document.getElementById('debrid_tb')?.checked || document.getElementById('debrid_tb')?.disabled;
const c411Checked = document.getElementById('c411')?.checked || document.getElementById('c411')?.disabled;
const torr9Checked = document.getElementById('torr9')?.checked || document.getElementById('torr9')?.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', tbChecked ? 'block' : 'none');
setElementDisplay('tb_debrid-fields', torboxChecked ? 'block' : 'none');
setElementDisplay('c411-fields', c411Checked ? 'block' : 'none');
setElementDisplay('torr9-fields', torr9Checked ? 'block' : 'none');
// Traiter tous les débrideurs
allDebrids.forEach(id => {
@ -676,7 +689,7 @@ function loadData() {
zilean: true,
yggflix: true,
sharewood: false,
maxSize: '18',
maxSize: '150',
resultsPerQuality: '10',
maxResults: '30',
minCachedResults: '10',
@ -684,8 +697,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,
@ -693,7 +706,9 @@ function loadData() {
debrid_pm: false,
tb_usenet: false,
tb_search: false,
debrid_order: false
debrid_order: false,
c411: true,
torr9: true
};
Object.keys(defaultConfig).forEach(key => {
@ -734,6 +749,8 @@ function loadData() {
setElementValue('pm_token_info', decodedData.PMToken, '');
setElementValue('sharewoodPasskey', decodedData.sharewoodPasskey, '');
setElementValue('yggPasskey', decodedData.yggPasskey, '');
setElementValue('c411ApiKey', decodedData.c411ApiKey, '');
setElementValue('torr9ApiKey', decodedData.torr9ApiKey, '');
setElementValue('ApiKey', decodedData.apiKey, '');
setElementValue('exclusion-keywords', (decodedData.exclusionKeywords || []).join(', '), '');
@ -756,10 +773,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.');
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.');
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,
@ -768,6 +825,10 @@ function getLink(method) {
TBUsenet: document.getElementById('tb_usenet')?.checked,
TBSearch: document.getElementById('tb_search')?.checked,
sharewoodPasskey: document.getElementById('sharewoodPasskey')?.value,
c411: document.getElementById('c411')?.checked || document.getElementById('c411')?.disabled || false,
torr9: document.getElementById('torr9')?.checked || document.getElementById('torr9')?.disabled || false,
c411ApiKey: document.getElementById('c411ApiKey')?.value || '',
torr9ApiKey: document.getElementById('torr9ApiKey')?.value || '',
maxSize: parseInt(document.getElementById('maxSize').value) || 16,
exclusionKeywords: document.getElementById('exclusion-keywords').value.split(',').map(keyword => keyword.trim()).filter(keyword => keyword !== ''),
languages: languages.filter(lang => document.getElementById(lang).checked),
@ -785,7 +846,7 @@ function getLink(method) {
yggtorrentCtg: document.getElementById('ctg_yggtorrent')?.checked,
yggflixCtg: document.getElementById('ctg_yggflix')?.checked,
yggPasskey: document.getElementById('yggPasskey')?.value,
torrenting: document.getElementById('torrenting').checked,
torrenting: false,
debrid: false,
metadataProvider: document.getElementById('tmdb').checked ? 'tmdb' : 'cinemeta',
debridDownloader: document.querySelector('input[name="debrid_downloader"]:checked')?.value,
@ -814,8 +875,9 @@ function getLink(method) {
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.c411 && document.getElementById('c411ApiKey') && !data.c411ApiKey) missingRequiredFields.push("C411 API Key");
if (data.torr9 && document.getElementById('torr9ApiKey') && !data.torr9ApiKey) missingRequiredFields.push("Torr9 API Key");
if (data.stremthru && !data.stremthruUrl) missingRequiredFields.push("StremThru URL");
if (missingRequiredFields.length > 0) {
@ -827,10 +889,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;
@ -841,11 +899,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`;
@ -867,3 +920,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,6 +51,9 @@
<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>
@ -238,19 +241,6 @@
placeholder="Enter your PikPak email:password">
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="torrenting" name="torrenting" type="checkbox"
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 <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>
@ -372,6 +362,32 @@
you may prefere to disable it to save time on search</span></p>
</div>
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="c411" name="c411" type="checkbox" checked
data-unique-account="{{ c411_unique_account|lower }}"
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="c411" class="font-medium text-white">Enable C411 API</label>
<p class="text-gray-500 dark:text-gray-300">Use C411 Torznab API to find more results.</p>
</div>
</div>
<div class="relative flex gap-x-3">
<div class="flex h-6 items-center">
<input id="torr9" name="torr9" type="checkbox" checked
data-unique-account="{{ torr9_unique_account|lower }}"
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="torr9" class="font-medium text-white">Enable Torr9 API</label>
<p class="text-gray-500 dark:text-gray-300">Use Torr9 Torznab API to find more results.</p>
</div>
</div>
</div>
</div>
@ -513,7 +529,7 @@
</div>
</div>
</div>
{% if not pm_unique_account %}
<div id="pm_debrid-fields" style="display: none;">
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
@ -640,19 +656,38 @@
</div>
{% endif %}
{% if not ygg_unique_account %}
<div id="ygg-fields" style="display: none;">
{% if not c411_unique_account %}
<div id="c411-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">YggTorrent Login</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Enter your YggTorrent account
information here.</p>
<h2 class="text-white font-semibold leading-7">C411 Configuration</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Enter your C411 API key here.</p>
</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="yggPasskey"
class="block text-sm font-medium leading-6 text-white">YggTorrent PassKey</label>
<input type="text" name="yggPasskey" id="yggPasskey"
<label for="c411ApiKey"
class="block text-sm font-medium leading-6 text-white">API Key</label>
<input type="text" name="c411ApiKey" id="c411ApiKey"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 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>
</div>
{% endif %}
{% if not torr9_unique_account %}
<div id="torr9-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">Torr9 Configuration</h2>
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Enter your Torr9 API key here.</p>
</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="torr9ApiKey"
class="block text-sm font-medium leading-6 text-white">API Key</label>
<input type="text" name="torr9ApiKey" id="torr9ApiKey"
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 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>
@ -671,7 +706,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
@ -692,7 +727,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
@ -745,7 +780,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
@ -766,7 +801,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
@ -851,7 +886,7 @@
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>
</label>
</div>
</div>
</div>
@ -877,7 +912,7 @@
<label for="maxSize" class="block text-sm font-medium leading-6 text-white">Maximum file
size
(GB)</label>
<input type="number" name="maxSize" id="maxSize" min="0" step="0.1" value="18"
<input type="number" name="maxSize" id="maxSize" min="0" step="0.1" value="200"
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>
@ -907,9 +942,9 @@
</div>
<div class="mt-6 flex items-center justify-center gap-x-6">
<a id="install" onclick="return getLink('link')"
<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')"
<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>

Binary file not shown.

Binary file not shown.

View file

View file

@ -0,0 +1,153 @@
import re
import aiohttp
import xml.etree.ElementTree as ET
from typing import List, Optional
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class C411RawResult:
def __init__(self):
self.raw_title: Optional[str] = None
self.size: Optional[str] = None
self.link: Optional[str] = None
self.indexer: str = "C411 - API"
self.seeders: int = 0
self.magnet: Optional[str] = None
self.info_hash: Optional[str] = None
self.privacy: str = "public"
class C411API:
TORZNAB_NS = {"torznab": "http://torznab.com/schemas/2015/feed"}
def __init__(self, session: Optional[aiohttp.ClientSession] = None, api_key: Optional[str] = None):
self.base_url = settings.c411_url.rstrip("/") + "/api"
self.api_key = api_key if api_key is not None else settings.c411_api_key
self._external_session = session is not None
self._session = session
self._timeout = aiohttp.ClientTimeout(sock_read=2)
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=self._timeout)
self._external_session = False
return self._session
async def _request_xml(self, params: dict) -> Optional[str]:
if not self.api_key:
logger.warning("C411: API key not configured (C411_API_KEY), skipping request")
return None
# Torznab standard : apikey en query param (pas Bearer header)
params["apikey"] = self.api_key
session = await self._get_session()
try:
async with session.get(
self.base_url, params=params, allow_redirects=True,
timeout=aiohttp.ClientTimeout(sock_read=2, total=5)
) as response:
response.raise_for_status()
return await response.text()
except aiohttp.ClientError as e:
logger.error(f"C411: HTTP error: {e}")
return None
except Exception as e:
logger.error(f"C411: Unexpected error: {e}")
return None
def _parse_xml(self, xml_content: str) -> List[C411RawResult]:
results = []
try:
root = ET.fromstring(xml_content)
except ET.ParseError as e:
logger.error(f"C411: XML parse error: {e}")
return results
for item in root.findall(".//item"):
try:
result = C411RawResult()
seeders_el = item.find(
'.//torznab:attr[@name="seeders"]', self.TORZNAB_NS
)
result.seeders = int(seeders_el.attrib["value"]) if seeders_el is not None else 0
title_el = item.find("title")
if title_el is None or not title_el.text:
continue
result.raw_title = title_el.text
size_el = item.find("size")
result.size = size_el.text if size_el is not None else "0"
link_el = item.find("link")
result.link = link_el.text if link_el is not None else None
magnet_el = item.find(
'.//torznab:attr[@name="magneturl"]', self.TORZNAB_NS
)
result.magnet = magnet_el.attrib["value"] if magnet_el is not None else None
hash_el = item.find(
'.//torznab:attr[@name="infohash"]', self.TORZNAB_NS
)
result.info_hash = hash_el.attrib["value"] if hash_el is not None else None
type_el = item.find("type")
result.privacy = type_el.text if type_el is not None else "public"
# Extraire hash depuis magnet si manquant
if not result.info_hash and result.magnet:
m = re.search(r"btih:([a-fA-F0-9]{40})", result.magnet, re.IGNORECASE)
if m:
result.info_hash = m.group(1).lower()
if result.info_hash and len(result.info_hash) == 40:
results.append(result)
except Exception as e:
logger.debug(f"C411: Error parsing item: {e}")
continue
return results
async def search_movie(
self,
tmdb_id: Optional[str] = None,
title: Optional[str] = None,
) -> List[C411RawResult]:
params = {"t": "movie", "cat": "2000"}
if tmdb_id:
params["tmdbid"] = tmdb_id
elif title:
params["q"] = title
else:
return []
xml = await self._request_xml(params)
results = self._parse_xml(xml) if xml else []
logger.info(f"C411: search_movie tmdb={tmdb_id}{len(results)} results")
return results
async def search_series(
self,
tmdb_id: Optional[str] = None,
title: Optional[str] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> List[C411RawResult]:
params = {"t": "tvsearch", "cat": "5000"}
if tmdb_id:
params["tmdbid"] = tmdb_id
elif title:
params["q"] = title
else:
return []
if season is not None:
params["season"] = season
if episode is not None:
params["ep"] = episode
xml = await self._request_xml(params)
results = self._parse_xml(xml) if xml else []
logger.info(f"C411: search_series tmdb={tmdb_id} s={season} e={episode}{len(results)} results")
return results

View file

@ -0,0 +1,66 @@
from RTN import parse
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.utils.detection import detect_languages
from urllib.parse import quote
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class C411Result:
def __init__(self):
self.raw_title = None
self.size = None
self.link = None
self.indexer = "C411 - API"
self.seeders = 0
self.magnet = None
self.info_hash = None
self.privacy = "public"
self.languages = None
self.type = None
self.parsed_data = None
self.torrent_download = None
def convert_to_torrent_item(self):
parsed_data = self.parsed_data or parse(self.raw_title)
return TorrentItem(
raw_title=self.raw_title,
size=self.size,
magnet=self.magnet,
info_hash=self.info_hash.lower() if self.info_hash else None,
link=self.link or self.magnet,
seeders=self.seeders,
languages=self.languages,
indexer=self.indexer,
privacy=self.privacy,
type=self.type,
parsed_data=parsed_data,
torrent_download=self.torrent_download,
tmdb_id=self.tmdb_id,
)
def from_api_item(self, api_item, media):
self.info_hash = api_item.info_hash.lower() if api_item.info_hash else None
if not self.info_hash or len(self.info_hash) != 40:
raise ValueError(f"Invalid info_hash: {self.info_hash}")
parsed = parse(api_item.raw_title)
self.raw_title = parsed.raw_title
self.parsed_data = parsed
self.size = api_item.size or "0"
c411_tracker = settings.c411_passkey or ""
if c411_tracker:
self.magnet = f"magnet:?xt=urn:btih:{self.info_hash}&dn={self.raw_title}&tr={quote(c411_tracker, safe='')}"
else:
self.magnet = f"magnet:?xt=urn:btih:{self.info_hash}&dn={self.raw_title}"
self.link = self.magnet
self.seeders = api_item.seeders or 0
self.privacy = api_item.privacy or "public"
self.languages = detect_languages(self.raw_title, default_language="fr")
self.type = media.type
self.tmdb_id = getattr(media, 'tmdb_id', None)
base = settings.c411_url.rstrip("/")
self.torrent_download = f"{base}/api?t=get&id={self.info_hash}&apikey={settings.c411_api_key}"
return self

View file

@ -0,0 +1,65 @@
from typing import List, Optional, Union
import aiohttp
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
from stream_fusion.utils.c411.c411_api import C411API
from stream_fusion.utils.c411.c411_result import C411Result
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
class C411Service:
def __init__(self, config: dict, session: Optional[aiohttp.ClientSession] = None):
self.config = config
if settings.c411_unique_account and settings.c411_api_key:
api_key = settings.c411_api_key
else:
api_key = config.get("c411ApiKey") or settings.c411_api_key
self.api = C411API(session=session, api_key=api_key)
self.has_tmdb = config.get("metadataProvider") == "tmdb"
async def search(self, media: Union[Movie, Series]) -> List[C411Result]:
try:
if isinstance(media, Movie):
return await self._search_movie(media)
elif isinstance(media, Series):
return await self._search_series(media)
else:
raise TypeError("Only Movie and Series are supported")
except Exception as e:
logger.error(f"C411: Search error: {e}")
return []
async def _search_movie(self, media: Movie) -> List[C411Result]:
logger.info(f"C411: Searching movie: {media.titles[0]}")
tmdb_id = str(media.tmdb_id) if self.has_tmdb and media.tmdb_id else None
if not tmdb_id:
logger.debug(f"C411: No TMDB ID available, skipping search for '{media.titles[0]}'")
return []
raw = await self.api.search_movie(tmdb_id=tmdb_id)
logger.info(f"C411: {len(raw)} raw results for movie '{media.titles[0]}'")
return self._build_results(raw, media)
async def _search_series(self, media: Series) -> List[C411Result]:
logger.info(f"C411: Searching series (global): {media.titles[0]}")
tmdb_id = str(media.tmdb_id) if self.has_tmdb and media.tmdb_id else None
if not tmdb_id:
logger.debug(f"C411: No TMDB ID available, skipping search for '{media.titles[0]}'")
return []
# Recherche globale (sans saison/épisode) pour tout stocker en Postgres
raw = await self.api.search_series(tmdb_id=tmdb_id)
logger.info(f"C411: {len(raw)} raw results for '{media.titles[0]}' (global)")
return self._build_results(raw, media)
def _build_results(self, raw_results, media) -> List[C411Result]:
results = []
for item in raw_results:
try:
result = C411Result().from_api_item(item, media)
results.append(result)
except ValueError as e:
logger.debug(f"C411: Skipping item — {e}")
return results

View file

@ -11,6 +11,9 @@ from stream_fusion.settings import settings
def search_public(media):
if not settings.public_cache_url:
return None
logger.info("Searching for public cached " + media.type + " results")
url = settings.public_cache_url + "getResult/" + media.type + "/"
# Without that, the cache doesn't return results. Maybe make multiple requests? One for each language, just like jackett?
@ -19,10 +22,21 @@ def search_public(media):
cache_search["language"] = cache_search["languages"][0]
# Wtf, why do we need to use __dict__ here? And also, why is it stuck when we use media directly?
response = requests.get(url, json=cache_search)
return response.json()
try:
response.raise_for_status() # Lève une exception pour les codes d'état HTTP d'erreur (4xx ou 5xx)
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Search: Erreur lors de la récupération des résultats du cache public: {e}")
return None
except json.JSONDecodeError as e:
logger.error(f"Search: Erreur de décodage JSON pour le cache public: {e}. Contenu de la réponse: {response.text[:200]}")
return None
def cache_public(torrents: List[TorrentItem], media):
if not settings.public_cache_url:
return
if os.getenv("NODE_ENV") == "development":
return

View file

@ -25,7 +25,9 @@ class RedisCache(CacheBase):
try:
self._redis_client = Redis.from_url(
self.redis_url,
max_connections=10
max_connections=100,
socket_connect_timeout=5,
socket_timeout=10
)
except Exception as e:
self.logger.error(f"RedisCache: Failed to create Redis client: {e}")
@ -216,10 +218,19 @@ class RedisCache(CacheBase):
async def close(self):
if self._redis_client:
await self._redis_client.close()
try:
# Set a short timeout for closing to avoid blocking
await asyncio.wait_for(
self._redis_client.close(),
timeout=2.0
)
except asyncio.TimeoutError:
self.logger.warning("RedisCache: Timeout while closing Redis connection, ignoring")
except Exception as e:
self.logger.error(f"RedisCache: Error while closing Redis connection: {e}")
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
await self.close()

View file

@ -1,5 +1,6 @@
# alldebrid.py
# alldebrid.py - AllDebrid v4.1 API (using magnet/status for files)
import uuid
import aiohttp
from urllib.parse import unquote
from fastapi import HTTPException
@ -10,9 +11,29 @@ from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
def flatten_files(files, result=None):
if result is None:
result = []
if not isinstance(files, list):
return result
for file_item in files:
if not isinstance(file_item, dict):
continue
result.append(file_item)
children = file_item.get("e", [])
if children and isinstance(children, list):
flatten_files(children, result)
return result
class AllDebrid(BaseDebrid):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session: aiohttp.ClientSession = None):
super().__init__(config, session)
self.base_url = f"{settings.ad_base_url}/{settings.ad_api_version}/"
self.agent = settings.ad_user_app
@ -30,111 +51,166 @@ class AllDebrid(BaseDebrid):
else:
return {"Authorization": f"Bearer {self.config.get('ADToken')}"}
def add_magnet(self, magnet, ip=None):
async def add_magnet(self, magnet, ip=None):
url = f"{self.base_url}magnet/upload?agent={self.agent}"
data = {"magnets[]": magnet}
return self.json_response(url, method='post', headers=self.get_headers(), data=data)
return await self.json_response(url, method='post', headers=self.get_headers(), data=data)
def add_torrent(self, torrent_file, ip=None):
async def add_torrent(self, torrent_file, ip=None):
url = f"{self.base_url}magnet/upload/file?agent={self.agent}"
files = {"files[]": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')}
return self.json_response(url, method='post', headers=self.get_headers(), files=files)
return await self.json_response(url, method='post', headers=self.get_headers(), files=files)
def check_magnet_status(self, id, ip=None):
url = f"{self.base_url}magnet/status?agent={self.agent}&id={id}"
return self.json_response(url, method='get', headers=self.get_headers())
async def get_magnet_files(self, id, ip=None):
"""Get files from magnet using v4 API"""
url = f"{settings.ad_base_url}/v4/magnet/files"
data = {"id[]": id, "agent": self.agent}
return await self.json_response(url, method='post', headers=self.get_headers(), data=data)
def unrestrict_link(self, link, ip=None):
async def unrestrict_link(self, link, ip=None):
url = f"{self.base_url}link/unlock?agent={self.agent}&link={link}"
return self.json_response(url, method='get', headers=self.get_headers())
return await self.json_response(url, method='get', headers=self.get_headers())
def get_stream_link(self, query, config, ip=None):
async def get_stream_link(self, query, config=None, ip=None):
magnet = query['magnet']
stream_type = query['type']
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
torrent_file_content = query.get("torrent_file_content", None)
torrent_id = self.add_magnet_or_torrent(magnet, torrent_download, ip)
# Add magnet or torrent to AllDebrid
torrent_id = await self.add_magnet_or_torrent(magnet, torrent_download, torrent_file_content, ip)
torrent_id = str(torrent_id) if torrent_id else ""
logger.info(f"AllDebrid: Torrent ID: {torrent_id}")
if not self.wait_for_ready_status(
lambda: self.check_magnet_status(torrent_id, ip)["data"]["magnets"]["status"] == "Ready"):
logger.error("AllDebrid: Torrent not ready, caching in progress.")
if not torrent_id or torrent_id.startswith("Error"):
logger.error(f"AllDebrid: Failed to add torrent: {torrent_id}")
return settings.no_cache_video_url
logger.info("AllDebrid: Torrent is ready.")
logger.info(f"AllDebrid: Retrieving data for torrent ID: {torrent_id}")
data = self.check_magnet_status(torrent_id, ip)["data"]
logger.info(f"AllDebrid: Data retrieved for torrent ID")
# Get files from magnet using v4 API
logger.info(f"AllDebrid: Retrieving files for torrent ID: {torrent_id}")
try:
files_response = await self.get_magnet_files(torrent_id, ip)
logger.debug(f"AllDebrid: Files response: {files_response}")
if not files_response:
logger.error("AllDebrid: Null response from get_magnet_files")
return settings.no_cache_video_url
if files_response.get("status") != "success":
logger.warning(f"AllDebrid: get_magnet_files returned error: {files_response.get('error')}")
return settings.no_cache_video_url
if "data" not in files_response:
logger.error("AllDebrid: No data in files response")
return settings.no_cache_video_url
magnets = files_response["data"].get("magnets", [])
if not magnets or len(magnets) == 0:
logger.error("AllDebrid: No magnet data in files response")
return settings.no_cache_video_url
magnet_data = magnets[0]
files = magnet_data.get("files", [])
# Flatten nested file structure
files = flatten_files(files)
logger.info(f"AllDebrid: Retrieved {len(files)} files (after flattening)")
except Exception as e:
logger.error(f"AllDebrid: Error getting magnet files: {str(e)}")
import traceback
logger.debug(f"AllDebrid: Traceback: {traceback.format_exc()}")
return settings.no_cache_video_url
link = settings.no_cache_video_url
if stream_type == "movie":
logger.info("AllDebrid: Getting link for movie")
link = max(data["magnets"]['links'], key=lambda x: x['size'])['link']
logger.info("AllDebrid: Finding largest file for movie")
try:
largest_file = max(files, key=lambda x: x.get("s", 0)) if files else None
if largest_file and "l" in largest_file:
link = largest_file["l"]
logger.info(f"AllDebrid: Found movie link")
else:
logger.error("AllDebrid: No valid link found for movie")
except Exception as e:
logger.error(f"AllDebrid: Error processing movie: {str(e)}")
elif stream_type == "series":
numeric_season = int(query['season'].replace("S", ""))
numeric_episode = int(query['episode'].replace("E", ""))
logger.info(f"AllDebrid: Getting link for series S{numeric_season:02d}E{numeric_episode:02d}")
logger.info(f"AllDebrid: Finding S{numeric_season:02d}E{numeric_episode:02d}")
matching_files = []
for file in data["magnets"]["links"]:
filename = file["filename"]
logger.debug(f"AllDebrid: Checking file: {filename}")
if season_episode_in_filename(filename, numeric_season, numeric_episode):
logger.debug(f"AllDebrid: ✓ Match found with RTN parser: {filename}")
matching_files.append(file)
try:
matching_files = []
for file_info in files:
if isinstance(file_info, dict):
filename = file_info.get("n", "")
if season_episode_in_filename(filename, numeric_season, numeric_episode):
logger.debug(f"AllDebrid: ✓ Match: {filename}")
matching_files.append(file_info)
else:
import re
episode_patterns = [
rf"[Ss]{numeric_season:02d}[Ee]{numeric_episode:02d}",
rf"[Ss]{numeric_season}[Ee]{numeric_episode:02d}",
rf"{numeric_season:02d}x{numeric_episode:02d}",
rf"{numeric_season}x{numeric_episode:02d}",
rf"[Ss]eason.{numeric_season:02d}.*[Ee]{numeric_episode:02d}",
rf"[Ss]eason.{numeric_season}.*[Ee]{numeric_episode:02d}",
rf"[Ss]{numeric_season:02d}.*[Ee]pisode.{numeric_episode:02d}",
rf"[Ss]{numeric_season}.*[Ee]pisode.{numeric_episode:02d}",
rf"\b{numeric_episode:03d}\b",
rf"[Ee]p\.?\s*{numeric_episode:03d}\b",
rf"[Ee]pisode\s*{numeric_episode:03d}\b",
]
for pattern in episode_patterns:
if re.search(pattern, filename, re.IGNORECASE):
logger.debug(f"AllDebrid: ✓ Match with pattern: {filename}")
matching_files.append(file_info)
break
if matching_files:
target_file = max(matching_files, key=lambda x: x.get("s", 0))
if "l" in target_file:
link = target_file["l"]
logger.info(f"AllDebrid: Found episode link")
else:
logger.error("AllDebrid: Matching file has no link")
else:
import re
episode_patterns = [
rf"[Ss]{numeric_season:02d}[Ee]{numeric_episode:02d}",
rf"[Ss]{numeric_season}[Ee]{numeric_episode:02d}",
rf"{numeric_season:02d}x{numeric_episode:02d}",
rf"{numeric_season}x{numeric_episode:02d}",
rf"[Ss]eason.{numeric_season:02d}.*[Ee]{numeric_episode:02d}",
rf"[Ss]eason.{numeric_season}.*[Ee]{numeric_episode:02d}",
rf"[Ss]{numeric_season:02d}.*[Ee]pisode.{numeric_episode:02d}",
rf"[Ss]{numeric_season}.*[Ee]pisode.{numeric_episode:02d}",
]
match_found = False
for pattern in episode_patterns:
if re.search(pattern, filename, re.IGNORECASE):
logger.debug(f"AllDebrid: ✓ Match found with improved pattern '{pattern}': {filename}")
matching_files.append(file)
match_found = True
break
if not match_found:
logger.debug(f"AllDebrid: ✗ No match: {filename}")
logger.warning(f"AllDebrid: No files found for S{numeric_season:02d}E{numeric_episode:02d}")
except Exception as e:
logger.error(f"AllDebrid: Error processing series: {str(e)}")
if len(matching_files) == 0:
logger.warning(f"AllDebrid: No matching files found for S{numeric_season:02d}E{numeric_episode:02d}")
return settings.no_cache_video_url
else:
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.")
if link == settings.no_cache_video_url:
logger.info("AllDebrid: Video not cached, returning NO_CACHE_VIDEO_URL")
logger.info("AllDebrid: No link found, returning no-cache URL")
return link
logger.info(f"AllDebrid: Retrieved link: {link}")
logger.info(f"AllDebrid: Retrieved link successfully")
unlocked_link_data = self.unrestrict_link(link, ip)
# Try to unrestrict the link
try:
unlocked_response = await self.unrestrict_link(link, ip)
if unlocked_response and unlocked_response.get("status") == "success" and "data" in unlocked_response:
final_link = unlocked_response["data"].get("link", link)
logger.info(f"AllDebrid: Link unrestricted")
return final_link
except Exception as e:
logger.debug(f"AllDebrid: Could not unrestrict link: {str(e)}")
if not unlocked_link_data:
logger.error("AllDebrid: Failed to unlock link.")
raise HTTPException(status_code=500, detail="Failed to unlock link in AllDebrid.")
return link
logger.info(f"AllDebrid: Unrestricted link: {unlocked_link_data['data']['link']}")
return unlocked_link_data["data"]["link"]
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
if len(hashes_or_magnets) == 0:
logger.info("AllDebrid: No hashes to be sent.")
logger.info("AllDebrid: No hashes to check")
return {"status": "success", "data": {"magnets": []}}
result_magnets = []
@ -155,30 +231,59 @@ class AllDebrid(BaseDebrid):
return {"status": "success", "data": {"magnets": result_magnets}}
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
async def add_magnet_or_torrent(self, magnet, torrent_download=None, torrent_file_content=None, ip=None):
logger.debug(f"AllDebrid: Adding magnet or torrent")
torrent_id = ""
if torrent_download is None:
logger.info(f"AllDebrid: Adding magnet")
magnet_response = self.add_magnet(magnet, ip)
logger.info(f"AllDebrid: Add magnet response received")
if not magnet_response or "status" not in magnet_response or magnet_response["status"] != "success":
return "Error: Failed to add magnet."
# PRIORITE 1: Use cached .torrent file
if torrent_file_content is not None:
logger.info(f"AllDebrid: Attempting to add cached .torrent file")
try:
upload_response = await self.add_torrent(torrent_file_content, ip)
if upload_response and upload_response.get("status") == "success":
files = upload_response.get("data", {}).get("files", [])
if files and len(files) > 0:
torrent_id = files[0].get("id")
if torrent_id:
logger.info(f"AllDebrid: Successfully added cached .torrent, ID: {torrent_id}")
return str(torrent_id)
logger.warning(f"AllDebrid: Failed to add cached .torrent")
except Exception as e:
logger.warning(f"AllDebrid: Exception with cached .torrent: {str(e)}")
torrent_id = magnet_response["data"]["magnets"][0]["id"]
else:
logger.info(f"AllDebrid: Downloading torrent file")
torrent_file = self.download_torrent_file(torrent_download)
logger.info(f"AllDebrid: Torrent file downloaded")
# PRIORITE 2: Download and add .torrent file
if torrent_download is not None:
logger.info(f"AllDebrid: Downloading and adding .torrent file")
try:
torrent_file = await self.download_torrent_file(torrent_download)
upload_response = await self.add_torrent(torrent_file, ip)
logger.info(f"AllDebrid: Adding torrent file")
upload_response = self.add_torrent(torrent_file, ip)
logger.info(f"AllDebrid: Add torrent file response received")
if upload_response and upload_response.get("status") == "success":
files = upload_response.get("data", {}).get("files", [])
if files and len(files) > 0:
torrent_id = files[0].get("id")
if torrent_id:
logger.info(f"AllDebrid: Successfully added downloaded .torrent, ID: {torrent_id}")
return str(torrent_id)
logger.warning(f"AllDebrid: Failed to add downloaded .torrent, falling back to magnet")
except Exception as e:
logger.warning(f"AllDebrid: Exception downloading .torrent: {str(e)}")
if not upload_response or "status" not in upload_response or upload_response["status"] != "success":
return "Error: Failed to add torrent file in AllDebrid."
# PRIORITE 3: Fall back to magnet
logger.info(f"AllDebrid: Adding magnet link")
magnet_response = await self.add_magnet(magnet, ip)
torrent_id = upload_response["data"]["files"][0]["id"]
if not magnet_response or magnet_response.get("status") != "success":
logger.error(f"AllDebrid: Failed to add magnet: {magnet_response}")
return "Error: Failed to add magnet."
logger.info(f"AllDebrid: New torrent ID: {torrent_id}")
return torrent_id
try:
magnets = magnet_response.get("data", {}).get("magnets", [])
if magnets and len(magnets) > 0:
torrent_id = magnets[0].get("id")
logger.info(f"AllDebrid: Successfully added magnet, ID: {torrent_id}")
return str(torrent_id)
except Exception as e:
logger.error(f"AllDebrid: Exception extracting magnet ID: {str(e)}")
return "Error: Could not extract torrent ID."

View file

@ -1,18 +1,20 @@
from collections import deque
import json
import asyncio
import time
import requests
import aiohttp
from aiohttp_socks import ProxyConnector
from stream_fusion.logging_config import logger
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()
self._external_session = session is not None
self._session = session
# Rate limiters
self.global_limit = 250
@ -23,17 +25,28 @@ class BaseDebrid:
self.global_requests = deque()
self.torrent_requests = deque()
def _create_session(self):
session = requests.Session()
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),
}
return session
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create an aiohttp session with optional proxy support."""
if self._session is None or self._session.closed:
connector = None
if settings.proxy_url:
self.logger.debug(f"BaseDebrid: Using proxy: {settings.proxy_url}")
connector = ProxyConnector.from_url(str(settings.proxy_url))
def _rate_limit(self, requests_queue, limit, period):
timeout = aiohttp.ClientTimeout(total=30)
self._session = aiohttp.ClientSession(
connector=connector,
timeout=timeout
)
return self._session
async def close(self):
"""Close the session if we own it."""
if self._session and not self._external_session and not self._session.closed:
await self._session.close()
async def _rate_limit(self, requests_queue, limit, period):
"""Async rate limiter using asyncio.sleep."""
current_time = time.time()
while requests_queue and requests_queue[0] <= current_time - period:
@ -42,63 +55,83 @@ class BaseDebrid:
if len(requests_queue) >= limit:
sleep_time = requests_queue[0] - (current_time - period)
if sleep_time > 0:
time.sleep(sleep_time)
await asyncio.sleep(sleep_time)
requests_queue.append(time.time())
def _global_rate_limit(self):
self._rate_limit(self.global_requests, self.global_limit, self.global_period)
async def _global_rate_limit(self):
await self._rate_limit(self.global_requests, self.global_limit, self.global_period)
def _torrent_rate_limit(self):
self._rate_limit(self.torrent_requests, self.torrent_limit, self.torrent_period)
async def _torrent_rate_limit(self):
await 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, timeout=30, retry_on_429=True):
"""Make an async HTTP request and return JSON response."""
await self._global_rate_limit()
if "torrents" in url:
self._torrent_rate_limit()
await self._torrent_rate_limit()
session = await self._get_session()
request_timeout = aiohttp.ClientTimeout(total=timeout)
max_attempts = 5
for attempt in range(max_attempts):
try:
# Prepare request kwargs
kwargs = {
"headers": headers,
"timeout": request_timeout
}
if method == "get":
response = self.__session.get(url, headers=headers)
async with session.get(url, **kwargs) as response:
await self._log_and_raise(response)
return await self._parse_json_response(response, attempt, max_attempts)
elif method == "post":
response = self.__session.post(
url, data=data, headers=headers, files=files
)
if files:
# Handle file uploads with FormData
form_data = aiohttp.FormData()
if data:
for key, value in data.items():
form_data.add_field(key, str(value))
for key, file_tuple in files.items():
if isinstance(file_tuple, tuple):
filename, file_content, content_type = file_tuple
form_data.add_field(key, file_content, filename=filename, content_type=content_type)
else:
form_data.add_field(key, file_tuple)
async with session.post(url, data=form_data, **kwargs) as response:
await self._log_and_raise(response)
return await self._parse_json_response(response, attempt, max_attempts)
else:
async with session.post(url, data=data, **kwargs) as response:
await self._log_and_raise(response)
return await self._parse_json_response(response, attempt, max_attempts)
elif method == "put":
response = self.__session.put(url, data=data, headers=headers)
async with session.put(url, data=data, **kwargs) as response:
await self._log_and_raise(response)
return await self._parse_json_response(response, attempt, max_attempts)
elif method == "delete":
response = self.__session.delete(url, headers=headers)
async with session.delete(url, **kwargs) as response:
await self._log_and_raise(response)
return await self._parse_json_response(response, attempt, max_attempts)
else:
raise ValueError(f"BaseDebrid: Unsupported HTTP method: {method}")
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..."
)
time.sleep(wait_time)
else:
return None
except requests.exceptions.HTTPError as e:
status_code = e.response.status_code
except aiohttp.ClientResponseError as e:
status_code = e.status
if status_code == 429:
if not retry_on_429:
self.logger.warning("BaseDebrid: Rate limit exceeded. No retry configured, returning None immediately.")
return None
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)
elif 400 <= status_code < 500:
self.logger.error(
f"BaseDebrid: Client error occurred: {e}. Status code: {status_code}"
@ -113,7 +146,7 @@ class BaseDebrid:
self.logger.info(
f"BaseDebrid: Retrying in {wait_time} seconds..."
)
time.sleep(wait_time)
await asyncio.sleep(wait_time)
else:
return None
else:
@ -121,23 +154,26 @@ class BaseDebrid:
f"BaseDebrid: Unexpected HTTP error occurred: {e}. Status code: {status_code}"
)
return None
except requests.exceptions.ConnectionError as e:
except aiohttp.ClientConnectorError as e:
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)
else:
return None
except requests.exceptions.Timeout as e:
self.logger.error(f"BaseDebrid: Request timed out: {e}")
except asyncio.TimeoutError:
self.logger.error(f"BaseDebrid: Request timed out")
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)
else:
return None
except requests.exceptions.RequestException as e:
except aiohttp.ClientError as e:
self.logger.error(f"BaseDebrid: An unexpected error occurred: {e}")
return None
@ -146,30 +182,61 @@ class BaseDebrid:
)
return None
def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
async def _log_and_raise(self, response):
"""Log response body and headers on error before raising."""
if response.status >= 400:
try:
body = await response.text()
service = self.__class__.__name__
url = str(response.url)
headers = dict(response.headers)
self.logger.warning(f"{service}: HTTP {response.status} on {url} - body: {body[:500]} - headers: {headers}")
except Exception:
pass
response.raise_for_status()
async def _parse_json_response(self, response, attempt, max_attempts):
"""Parse JSON from response with error handling."""
try:
return await response.json()
except Exception as json_err:
text = await response.text()
self.logger.error(f"BaseDebrid: Invalid JSON response: {json_err}")
self.logger.debug(f"BaseDebrid: Response content: {text[:200]}...")
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)
return None
async def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
"""Async wait for ready status with polling."""
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():
if await check_status_func():
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):
"""Async download of torrent file."""
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=30)
async with session.get(download_url, timeout=timeout) as response:
response.raise_for_status()
return await response.read()
def get_stream_link(self, query, ip=None):
raise NotImplementedError
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
async def get_stream_link(self, query, ip=None):
raise NotImplementedError
def add_magnet(self, magnet, ip=None):
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
raise NotImplementedError
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def add_magnet(self, magnet, ip=None):
raise NotImplementedError
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
raise NotImplementedError

View file

@ -0,0 +1,14 @@
class DebridError(Exception):
def __init__(self, message: str, *, error_code: str = None, upstream_error_code: str = None):
self.error_code = error_code
self.upstream_error_code = upstream_error_code
super().__init__(message)
@property
def status_keys(self) -> list:
keys = []
if self.upstream_error_code:
keys.append(self.upstream_error_code)
if self.error_code and self.error_code not in keys:
keys.append(self.error_code)
return keys

View file

@ -3,31 +3,31 @@ from stream_fusion.logging_config import logger
class DebridLink(StremThru):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session=None):
super().__init__(config, session)
self.name = "DebridLink"
self.extension = "DL"
# Récupérer la clé API de DebridLink
self.api_key = config.get("debridlink_api_key", "")
# Configurer StremThru pour utiliser DebridLink
self.set_store_credentials("debridlink", self.api_key)
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
"""Vérifie la disponibilité des torrents en masse via StremThru"""
results = super().get_availability_bulk(hashes_or_magnets, ip)
results = await super().get_availability_bulk(hashes_or_magnets, ip)
logger.debug(f"DebridLink (via StremThru): {len(results)} torrents en cache trouvés")
return results
def add_magnet(self, magnet, ip=None):
async def add_magnet(self, magnet, ip=None):
"""Ajoute un magnet à DebridLink via StremThru"""
result = super().add_magnet(magnet, ip)
result = await super().add_magnet(magnet, ip)
logger.debug(f"DebridLink (via StremThru): Magnet ajouté avec succès: {result is not None}")
return result
def get_stream_link(self, query, ip=None):
async def get_stream_link(self, query, config=None, ip=None):
"""Génère un lien de streaming via StremThru"""
link = super().get_stream_link(query, ip)
link = await super().get_stream_link(query, config, ip)
logger.debug(f"DebridLink (via StremThru): Lien de streaming généré: {link is not None}")
return link

View file

@ -3,31 +3,31 @@ from stream_fusion.logging_config import logger
class EasyDebrid(StremThru):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session=None):
super().__init__(config, session)
self.name = "EasyDebrid"
self.extension = "ED"
# Récupérer la clé API d'EasyDebrid
self.api_key = config.get("easydebrid_api_key", "")
# Configurer StremThru pour utiliser EasyDebrid
self.set_store_credentials("easydebrid", self.api_key)
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
"""Vérifie la disponibilité des torrents en masse via StremThru"""
results = super().get_availability_bulk(hashes_or_magnets, ip)
results = await super().get_availability_bulk(hashes_or_magnets, ip)
logger.debug(f"EasyDebrid (via StremThru): {len(results)} torrents en cache trouvés")
return results
def add_magnet(self, magnet, ip=None):
async def add_magnet(self, magnet, ip=None):
"""Ajoute un magnet à EasyDebrid via StremThru"""
result = super().add_magnet(magnet, ip)
result = await super().add_magnet(magnet, ip)
logger.debug(f"EasyDebrid (via StremThru): Magnet ajouté avec succès: {result is not None}")
return result
def get_stream_link(self, query, ip=None):
async def get_stream_link(self, query, config=None, ip=None):
"""Génère un lien de streaming via StremThru"""
link = super().get_stream_link(query, ip)
link = await super().get_stream_link(query, config, ip)
logger.debug(f"EasyDebrid (via StremThru): Lien de streaming généré: {link is not None}")
return link

View file

@ -1,3 +1,4 @@
import aiohttp
from fastapi.exceptions import HTTPException
from stream_fusion.utils.debrid.alldebrid import AllDebrid
@ -13,120 +14,111 @@ from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
def get_all_debrid_services(config):
def get_all_debrid_services(config, session: aiohttp.ClientSession = None):
services = config['service']
debrid_service = []
if not services:
logger.error("No service configuration found in the config file.")
return []
# Vérifier si StremThru est activé
use_stremthru = config.get('stremthru', False)
for service in services:
if service == "Real-Debrid":
if use_stremthru:
# Utiliser StremThru pour Real-Debrid
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
st.extension = "ST:RD" # Préfixe ST pour indiquer StremThru
st.extension = "ST:RD"
debrid_service.append(st)
logger.debug("Real-Debrid (via StremThru): service added to be use")
else:
debrid_service.append(RealDebrid(config))
debrid_service.append(RealDebrid(config, session))
logger.debug("Real-Debrid: service added to be use")
if service == "AllDebrid":
if use_stremthru:
# Utiliser StremThru pour AllDebrid
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
st.extension = "ST:AD"
debrid_service.append(st)
logger.debug("AllDebrid (via StremThru): service added to be use")
else:
debrid_service.append(AllDebrid(config))
debrid_service.append(AllDebrid(config, session))
logger.debug("AllDebrid: service added to be use")
if service == "TorBox":
if use_stremthru:
# Utiliser StremThru pour TorBox
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("torbox", config.get("TBToken", ""))
st.extension = "ST:TB"
debrid_service.append(st)
logger.debug("TorBox (via StremThru): service added to be use")
else:
debrid_service.append(Torbox(config))
debrid_service.append(Torbox(config, session))
logger.debug("TorBox: service added to be use")
if service == "Premiumize":
if use_stremthru:
# Utiliser StremThru pour Premiumize
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("premiumize", config.get("PMToken", ""))
st.extension = "ST:PM"
debrid_service.append(st)
logger.debug("Premiumize (via StremThru): service added to be use")
else:
debrid_service.append(Premiumize(config))
debrid_service.append(Premiumize(config, session))
logger.debug("Premiumize: service added to be use")
if service == "Debrid-Link":
if use_stremthru:
# Utiliser StremThru pour Debrid-Link
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("debridlink", config.get("DLToken", ""))
st.extension = "ST:DL"
debrid_service.append(st)
logger.debug("Debrid-Link (via StremThru): service added to be use")
else:
debrid_service.append(DebridLink(config))
debrid_service.append(DebridLink(config, session))
logger.debug("Debrid-Link: service added to be use")
if service == "EasyDebrid":
if use_stremthru:
# Utiliser StremThru pour EasyDebrid
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
st.extension = "ST:ED"
debrid_service.append(st)
logger.debug("EasyDebrid (via StremThru): service added to be use")
else:
debrid_service.append(EasyDebrid(config))
debrid_service.append(EasyDebrid(config, session))
logger.debug("EasyDebrid: service added to be use")
if service == "Offcloud":
if use_stremthru:
# Utiliser StremThru pour Offcloud
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
st.extension = "ST:OC"
debrid_service.append(st)
logger.debug("Offcloud (via StremThru): service added to be use")
else:
debrid_service.append(Offcloud(config))
debrid_service.append(Offcloud(config, session))
logger.debug("Offcloud: service added to be use")
if service == "PikPak":
if use_stremthru:
# Utiliser StremThru pour PikPak
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
st.extension = "ST:PP"
debrid_service.append(st)
logger.debug("PikPak (via StremThru): service added to be use")
else:
debrid_service.append(PikPak(config))
debrid_service.append(PikPak(config, session))
logger.debug("PikPak: service added to be use")
if not debrid_service:
raise HTTPException(status_code=500, detail="Invalid service configuration.")
return debrid_service
def get_download_service(config):
def get_download_service(config, session: aiohttp.ClientSession = None):
if not settings.download_service:
service = config.get('debridDownloader')
if not service:
@ -143,58 +135,57 @@ def get_download_service(config):
)
else:
service = settings.download_service
# Vérifier si StremThru est activé
use_stremthru = config.get('stremthru', False)
if service == "Real-Debrid":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
return st
return RealDebrid(config)
return RealDebrid(config, session)
elif service == "AllDebrid":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
return st
return AllDebrid(config)
return AllDebrid(config, session)
elif service == "TorBox":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("torbox", config.get("TBToken", ""))
return st
return Torbox(config)
return Torbox(config, session)
elif service == "Premiumize":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("premiumize", config.get("PMToken", ""))
return st
return Premiumize(config)
return Premiumize(config, session)
elif service == "Debrid-Link":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("debridlink", config.get("DLToken", ""))
return st
return DebridLink(config)
return DebridLink(config, session)
elif service == "EasyDebrid":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
return st
return EasyDebrid(config)
return EasyDebrid(config, session)
elif service == "Offcloud":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
return st
return Offcloud(config)
return Offcloud(config, session)
elif service == "PikPak":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
return st
return PikPak(config)
return PikPak(config, session)
else:
logger.error(f"Invalid download service: {service}")
raise HTTPException(
@ -203,63 +194,62 @@ def get_download_service(config):
)
def get_debrid_service(config, service):
def get_debrid_service(config, service, session: aiohttp.ClientSession = None):
if not service:
service = settings.download_service
# Vérifier si StremThru est activé
use_stremthru = config.get('stremthru', False)
if service == "RD":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
return st
return RealDebrid(config)
return RealDebrid(config, session)
elif service == "AD":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
return st
return AllDebrid(config)
return AllDebrid(config, session)
elif service == "TB":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("torbox", config.get("TBToken", ""))
return st
return Torbox(config)
return Torbox(config, session)
elif service == "PM":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("premiumize", config.get("PMToken", ""))
return st
return Premiumize(config)
return Premiumize(config, session)
elif service == "DL":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("debridlink", config.get("DLToken", ""))
return st
return DebridLink(config)
return DebridLink(config, session)
elif service == "ED":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
return st
return EasyDebrid(config)
return EasyDebrid(config, session)
elif service == "OC":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
return st
return Offcloud(config)
return Offcloud(config, session)
elif service == "PP":
if use_stremthru:
st = StremThru(config)
st = StremThru(config, session)
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
return st
return PikPak(config)
return PikPak(config, session)
elif service == "ST":
return get_download_service(config)
return get_download_service(config, session)
else:
logger.error("Invalid service configuration return by stremio in the query.")
raise HTTPException(status_code=500, detail="Invalid service configuration return by stremio.")

View file

@ -3,32 +3,32 @@ from stream_fusion.logging_config import logger
class Offcloud(StremThru):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session=None):
super().__init__(config, session)
self.name = "Offcloud"
self.extension = "OC"
# Récupérer les identifiants Offcloud (email:password)
self.credentials = config.get("offcloud_credentials", "")
# Configurer StremThru pour utiliser Offcloud
self.set_store_credentials("offcloud", self.credentials)
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
"""Vérifie la disponibilité des torrents en masse via StremThru"""
results = super().get_availability_bulk(hashes_or_magnets, ip)
results = await super().get_availability_bulk(hashes_or_magnets, ip)
logger.debug(f"Offcloud (via StremThru): {len(results)} torrents en cache trouvés")
# Note: Pour Offcloud, la liste des fichiers est toujours vide selon la documentation StremThru
return results
def add_magnet(self, magnet, ip=None):
async def add_magnet(self, magnet, ip=None):
"""Ajoute un magnet à Offcloud via StremThru"""
result = super().add_magnet(magnet, ip)
result = await super().add_magnet(magnet, ip)
logger.debug(f"Offcloud (via StremThru): Magnet ajouté avec succès: {result is not None}")
return result
def get_stream_link(self, query, ip=None):
async def get_stream_link(self, query, config=None, ip=None):
"""Génère un lien de streaming via StremThru"""
link = super().get_stream_link(query, ip)
link = await super().get_stream_link(query, config, ip)
logger.debug(f"Offcloud (via StremThru): Lien de streaming généré: {link is not None}")
return link

View file

@ -3,31 +3,31 @@ from stream_fusion.logging_config import logger
class PikPak(StremThru):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session=None):
super().__init__(config, session)
self.name = "PikPak"
self.extension = "PP"
# Récupérer les identifiants PikPak (email:password)
self.credentials = config.get("pikpak_credentials", "")
# Configurer StremThru pour utiliser PikPak
self.set_store_credentials("pikpak", self.credentials)
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
"""Vérifie la disponibilité des torrents en masse via StremThru"""
results = super().get_availability_bulk(hashes_or_magnets, ip)
results = await super().get_availability_bulk(hashes_or_magnets, ip)
logger.debug(f"PikPak (via StremThru): {len(results)} torrents en cache trouvés")
return results
def add_magnet(self, magnet, ip=None):
async def add_magnet(self, magnet, ip=None):
"""Ajoute un magnet à PikPak via StremThru"""
result = super().add_magnet(magnet, ip)
result = await super().add_magnet(magnet, ip)
logger.debug(f"PikPak (via StremThru): Magnet ajouté avec succès: {result is not None}")
return result
def get_stream_link(self, query, ip=None):
async def get_stream_link(self, query, config=None, ip=None):
"""Génère un lien de streaming via StremThru"""
link = super().get_stream_link(query, ip)
link = await super().get_stream_link(query, config, ip)
logger.debug(f"PikPak (via StremThru): Lien de streaming généré: {link is not None}")
return link

View file

@ -1,64 +1,66 @@
import json
import asyncio
import aiohttp
import time
from stream_fusion.settings import settings
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.utils.general import get_info_hash_from_magnet, season_episode_in_filename
from stream_fusion.logging_config import logger
import time
class Premiumize(BaseDebrid):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session: aiohttp.ClientSession = None):
super().__init__(config, session)
self.base_url = "https://www.premiumize.me/api"
self.api_key = config.get('PMToken') or settings.pm_token
if not self.api_key:
logger.error("No Premiumize API key found in config or settings")
raise ValueError("Premiumize API key is required")
# Vérifier la validité du token
self._check_token()
def _check_token(self):
self._token_checked = False
async def _ensure_token_checked(self):
"""Lazy token check - called before first API operation"""
if not self._token_checked:
await self._check_token()
self._token_checked = True
async def _check_token(self):
"""Vérifier la validité du token en appelant l'API account/info"""
url = f"{self.base_url}/account/info"
response = self.json_response(
response = await self.json_response(
url,
method='post',
data={'apikey': self.api_key}
)
if not response or response.get("status") != "success":
logger.error(f"Invalid Premiumize API key: {self.api_key}")
raise ValueError("Invalid Premiumize API key")
logger.info("Premiumize API key is valid")
def add_magnet(self, magnet, ip=None):
async def add_magnet(self, magnet, ip=None):
await self._ensure_token_checked()
url = f"{self.base_url}/transfer/create?apikey={self.api_key}"
# Vérifier si c'est un pack de saison
info_hash = get_info_hash_from_magnet(magnet)
is_season_pack = self._check_if_season_pack(magnet)
form = {
'src': magnet,
'folder_name': f"season_pack_{info_hash}" if is_season_pack else None
}
response = self.json_response(url, method='post', data=form)
response = await self.json_response(url, method='post', data=form)
if is_season_pack and response and response.get("status") == "success":
# Si c'est un pack de saison, on attend que tous les fichiers soient disponibles
transfer_id = response.get("id")
if transfer_id:
# Attendre que le transfert soit terminé
if self._wait_for_season_pack(transfer_id):
# Une fois terminé, récupérer les détails du dossier
folder_details = self.get_folder_or_file_details(transfer_id)
if await self._wait_for_season_pack(transfer_id):
folder_details = await self.get_folder_or_file_details(transfer_id)
if folder_details and folder_details.get("content"):
# Trier les fichiers par taille pour prendre le plus gros fichier vidéo
video_files = [f for f in folder_details["content"]
video_files = [f for f in folder_details["content"]
if f.get("mime_type", "").startswith("video/")]
if video_files:
largest_file = max(video_files, key=lambda x: x.get("size", 0))
@ -69,15 +71,14 @@ class Premiumize(BaseDebrid):
"link": largest_file.get("link"),
"stream_link": largest_file.get("stream_link")
}
return response
def _check_if_season_pack(self, magnet):
"""Vérifie si le magnet link correspond à un pack de saison"""
# Vérifie les patterns communs dans le nom du torrent
name = magnet.lower()
season_indicators = [
"complete.season",
"complete.season",
"season.complete",
"s01.complete",
"saison.complete",
@ -92,46 +93,80 @@ class Premiumize(BaseDebrid):
]
return any(indicator in name for indicator in season_indicators)
def _wait_for_season_pack(self, transfer_id, timeout=300):
async def _wait_for_season_pack(self, transfer_id, timeout=300, max_retries=10):
"""Attend que tous les fichiers d'un pack de saison soient disponibles"""
start_time = time.time()
while time.time() - start_time < timeout:
transfer_info = self.get_folder_or_file_details(transfer_id)
if transfer_info and transfer_info.get("status") == "success":
# Vérifier si des fichiers vidéo sont présents
if transfer_info.get("content"):
video_files = [f for f in transfer_info["content"]
if f.get("mime_type", "").startswith("video/")]
if video_files:
return True
time.sleep(5)
retry_count = 0
while time.time() - start_time < timeout and retry_count < max_retries:
try:
transfer_info = await self.get_folder_or_file_details(transfer_id)
if transfer_info and transfer_info.get("status") == "success":
if transfer_info.get("content"):
video_files = [f for f in transfer_info["content"]
if f.get("mime_type", "").startswith("video/")]
if video_files:
logger.info(f"Season pack ready with {len(video_files)} video files")
return True
retry_count += 1
elapsed = time.time() - start_time
logger.debug(f"Waiting for season pack: {elapsed:.1f}s elapsed, retry {retry_count}/{max_retries}")
await asyncio.sleep(5)
except Exception as e:
logger.warning(f"Error waiting for season pack: {e}")
retry_count += 1
await asyncio.sleep(5)
logger.warning(f"Season pack timeout after {time.time() - start_time:.1f}s or {retry_count} retries")
return False
def add_torrent(self, torrent_file):
async def add_torrent(self, torrent_file):
await self._ensure_token_checked()
url = f"{self.base_url}/transfer/create?apikey={self.api_key}"
form = {'file': torrent_file}
return self.json_response(url, method='post', data=form)
return await self.json_response(url, method='post', data=form)
def list_transfers(self):
async def list_transfers(self):
await self._ensure_token_checked()
url = f"{self.base_url}/transfer/list?apikey={self.api_key}"
return self.json_response(url)
return await self.json_response(url)
def get_folder_or_file_details(self, item_id, is_folder=True):
if is_folder:
logger.info(f"Getting folder details with id: {item_id}")
url = f"{self.base_url}/folder/list?id={item_id}&apikey={self.api_key}"
else:
logger.info(f"Getting file details with id: {item_id}")
url = f"{self.base_url}/item/details?id={item_id}&apikey={self.api_key}"
return self.json_response(url)
async def get_folder_or_file_details(self, item_id, is_folder=True):
"""Get folder or file details"""
await self._ensure_token_checked()
try:
if is_folder:
logger.debug(f"Getting folder details with id: {item_id}")
url = f"{self.base_url}/folder/list?id={item_id}&apikey={self.api_key}"
else:
logger.debug(f"Getting file details with id: {item_id}")
url = f"{self.base_url}/item/details?id={item_id}&apikey={self.api_key}"
def get_availability(self, hash):
response = await self.json_response(url)
if response is None:
logger.warning(f"No response from Premiumize API for item {item_id}")
return None
return response
except asyncio.TimeoutError as e:
logger.error(f"Timeout getting details for item {item_id}: {e}")
return None
except Exception as e:
logger.error(f"Error getting details for item {item_id}: {e}")
return None
async def get_availability(self, hash):
"""Get availability for a single hash"""
await self._ensure_token_checked()
if not hash:
return {"transcoded": [False]}
url = f"{self.base_url}/cache/check?apikey={self.api_key}&items[]={hash}"
response = self.json_response(url)
response = await self.json_response(url)
if not response or response.get("status") != "success":
logger.error("Invalid response from Premiumize API")
@ -141,21 +176,17 @@ class Premiumize(BaseDebrid):
"transcoded": response.get("transcoded", [False])
}
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
"""Get availability for multiple hashes or magnets"""
await self._ensure_token_checked()
if not hashes_or_magnets:
return {}
logger.info(f"Checking availability for {len(hashes_or_magnets)} items")
logger.debug(f"Using Premiumize API key: {self.api_key}")
# Construire l'URL avec les paramètres
params = []
for hash in hashes_or_magnets:
params.append(f"items[]={hash}")
url = f"{self.base_url}/cache/check"
response = self.json_response(
response = await self.json_response(
url,
method='post',
data={
@ -163,49 +194,45 @@ class Premiumize(BaseDebrid):
'items[]': hashes_or_magnets
}
)
logger.info(f"Raw Premiumize response: {response}")
if not response or response.get("status") != "success":
logger.error("Invalid response from Premiumize API")
return {}
# Format response to match expected structure
result = {}
for i, hash_or_magnet in enumerate(hashes_or_magnets):
# Vérifier si le fichier est disponible en utilisant le champ response
is_available = bool(response.get("response", [])[i]) if isinstance(response.get("response", []), list) and i < len(response["response"]) else False
# Récupérer le nom du fichier s'il est disponible
filename = None
if isinstance(response.get("filename", []), list) and i < len(response["filename"]):
filename = response["filename"][i]
# Récupérer la taille du fichier et la convertir en entier
filesize = 0
if isinstance(response.get("filesize", []), list) and i < len(response["filesize"]):
try:
filesize = int(response["filesize"][i]) if response["filesize"][i] is not None else 0
except (ValueError, TypeError):
filesize = 0
result[hash_or_magnet] = {
"transcoded": is_available,
"filename": filename,
"filesize": filesize
}
logger.info(f"Formatted response: {result}")
logger.info(f"Got availability for {len(result)} items")
return result
def start_background_caching(self, magnet, query=None):
async def start_background_caching(self, magnet, query=None):
"""Start caching a magnet link in the background."""
await self._ensure_token_checked()
logger.info(f"Starting background caching for magnet")
try:
# Create a transfer without waiting for completion
response = self.json_response(
response = await self.json_response(
f"{self.base_url}/transfer/create",
method="post",
data={"apikey": self.api_key, "src": magnet}
@ -226,130 +253,145 @@ class Premiumize(BaseDebrid):
logger.error(f"Error starting background caching: {str(e)}")
return False
def get_stream_link(self, query, config, ip=None):
"""Get a stream link for a magnet link"""
async def get_stream_link(self, query, config=None, ip=None, global_timeout=30):
"""Get a stream link for a magnet link with timeout protection"""
await self._ensure_token_checked()
start_time = time.time()
if not query:
return None
logger.info("Getting stream link for magnet")
# Vérifier si c'est une série et extraire la saison/épisode
season = None
episode = None
if isinstance(query, dict):
magnet = query.get("magnet")
if not magnet:
logger.error("No magnet link in query")
return None
# Vérifier si c'est une série
if query.get("type") == "series" and query.get("season") and query.get("episode"):
season = query["season"].replace("S", "") if isinstance(query["season"], str) else query["season"]
episode = query["episode"].replace("E", "") if isinstance(query["episode"], str) else query["episode"]
try:
season = int(season)
episode = int(episode)
except (ValueError, TypeError):
logger.error(f"Invalid season/episode format: {season}/{episode}")
return None
else:
magnet = query
# Essayer d'abord le téléchargement direct
logger.info(f"Getting stream link for magnet (global timeout: {global_timeout}s)")
try:
response = self.json_response(
f"{self.base_url}/transfer/directdl",
method="post",
data={"apikey": self.api_key, "src": magnet}
)
season = None
episode = None
if isinstance(query, dict):
magnet = query.get("magnet")
if not magnet:
logger.error("No magnet link in query")
return None
if response and response.get("status") == "success":
logger.info("Got direct download response")
if "content" in response and response["content"]:
# Si c'est une série, chercher l'épisode correspondant
if season is not None and episode is not None:
matching_files = []
for file in response["content"]:
filename = file.get("path", "").split("/")[-1]
if season_episode_in_filename(filename, season, episode):
matching_files.append(file)
if matching_files:
# Prendre le plus gros fichier parmi ceux qui correspondent
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
stream_link = selected_file.get("stream_link") or selected_file.get("link")
if query.get("type") == "series" and query.get("season") and query.get("episode"):
season = query["season"].replace("S", "") if isinstance(query["season"], str) else query["season"]
episode = query["episode"].replace("E", "") if isinstance(query["episode"], str) else query["episode"]
try:
season = int(season)
episode = int(episode)
except (ValueError, TypeError):
logger.error(f"Invalid season/episode format: {season}/{episode}")
return None
else:
magnet = query
def check_timeout(operation_name):
elapsed = time.time() - start_time
if elapsed > global_timeout:
logger.error(f"{operation_name} exceeded global timeout ({elapsed:.1f}s > {global_timeout}s)")
return True
return False
# Essayer d'abord le téléchargement direct
if check_timeout("Direct download"):
return None
try:
response = await self.json_response(
f"{self.base_url}/transfer/directdl",
method="post",
data={"apikey": self.api_key, "src": magnet}
)
if response and response.get("status") == "success":
logger.info("Got direct download response")
if "content" in response and response["content"]:
if season is not None and episode is not None:
matching_files = []
for file in response["content"]:
filename = file.get("path", "").split("/")[-1]
if season_episode_in_filename(filename, season, episode):
matching_files.append(file)
if matching_files:
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
stream_link = selected_file.get("stream_link") or selected_file.get("link")
if stream_link:
logger.info(f"Found matching episode stream link: {stream_link[:50]}...")
return stream_link
video_files = [f for f in response["content"]
if isinstance(f.get("path", ""), str) and
f.get("path", "").lower().endswith((".mkv", ".mp4", ".avi", ".m4v"))]
if video_files:
largest_file = max(video_files, key=lambda x: x.get("size", 0))
stream_link = largest_file.get("stream_link") or largest_file.get("link")
if stream_link:
logger.info(f"Found matching episode stream link: {stream_link[:50]}...")
logger.info(f"Found stream link: {stream_link[:50]}...")
return stream_link
# Si ce n'est pas une série ou si aucun fichier ne correspond,
# prendre le plus gros fichier vidéo
video_files = [f for f in response["content"]
if isinstance(f.get("path", ""), str) and
f.get("path", "").lower().endswith((".mkv", ".mp4", ".avi", ".m4v"))]
if video_files:
largest_file = max(video_files, key=lambda x: x.get("size", 0))
stream_link = largest_file.get("stream_link") or largest_file.get("link")
if stream_link:
logger.info(f"Found stream link: {stream_link[:50]}...")
return stream_link
elif response.get("location"):
logger.info(f"Found direct location: {response['location'][:50]}...")
return response["location"]
elif response.get("location"):
logger.info(f"Found direct location: {response['location'][:50]}...")
return response["location"]
except Exception as e:
logger.warning(f"Error in direct download: {str(e)}")
if check_timeout("Add magnet"):
return None
response = await self.add_magnet(magnet, ip)
if not response or response.get("status") != "success":
logger.error("Failed to add magnet")
return None
transfer_id = response.get("id")
if not transfer_id:
logger.error("No transfer ID in response")
return None
if check_timeout("Wait for transfer"):
return None
remaining_timeout = global_timeout - (time.time() - start_time)
if not await self._wait_for_season_pack(transfer_id, timeout=int(remaining_timeout)):
logger.error("Transfer timed out")
return None
if check_timeout("Get folder details"):
return None
folder_details = await self.get_folder_or_file_details(transfer_id)
if not folder_details or not folder_details.get("content"):
logger.error("No content in folder details")
return None
video_files = [f for f in folder_details["content"]
if isinstance(f.get("mime_type", ""), str) and
f.get("mime_type", "").startswith("video/")]
if season is not None and episode is not None:
matching_files = []
for file in video_files:
filename = file.get("name", "")
if season_episode_in_filename(filename, season, episode):
matching_files.append(file)
if matching_files:
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
logger.info(f"Selected matching episode file: {selected_file.get('name')}")
return selected_file.get("stream_link")
if video_files:
selected_file = max(video_files, key=lambda x: x.get("size", 0))
logger.info(f"Selected largest video file: {selected_file.get('name')}")
return selected_file.get("stream_link")
logger.error("No suitable video file found")
return None
except Exception as e:
logger.error(f"Error in direct download: {str(e)}")
# Continue avec la méthode standard si le téléchargement direct échoue
# Si le téléchargement direct a échoué, essayer la méthode standard
response = self.add_magnet(magnet, ip)
if not response or response.get("status") != "success":
logger.error("Failed to add magnet")
logger.error(f"Exception in get_stream_link: {str(e)}")
return None
# Récupérer l'ID du transfert
transfer_id = response.get("id")
if not transfer_id:
logger.error("No transfer ID in response")
return None
# Attendre que le transfert soit terminé
if not self._wait_for_season_pack(transfer_id):
logger.error("Transfer timed out")
return None
# Récupérer les détails du dossier
folder_details = self.get_folder_or_file_details(transfer_id)
if not folder_details or not folder_details.get("content"):
logger.error("No content in folder details")
return None
# Filtrer les fichiers vidéo
video_files = [f for f in folder_details["content"]
if isinstance(f.get("mime_type", ""), str) and
f.get("mime_type", "").startswith("video/")]
# Si c'est une série, chercher l'épisode correspondant
if season is not None and episode is not None:
matching_files = []
for file in video_files:
filename = file.get("name", "")
if season_episode_in_filename(filename, season, episode):
matching_files.append(file)
if matching_files:
# Prendre le plus gros fichier parmi ceux qui correspondent
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
logger.info(f"Selected matching episode file: {selected_file.get('name')}")
return selected_file.get("stream_link")
# Si ce n'est pas une série ou si aucun fichier ne correspond,
# prendre le plus gros fichier vidéo
if video_files:
selected_file = max(video_files, key=lambda x: x.get("size", 0))
logger.info(f"Selected largest video file: {selected_file.get('name')}")
return selected_file.get("stream_link")
logger.error("No suitable video file found")
return None
finally:
elapsed = time.time() - start_time
logger.debug(f"get_stream_link completed in {elapsed:.1f}s")

View file

@ -1,9 +1,9 @@
import re
import time
import asyncio
import aiohttp
from urllib.parse import unquote
from fastapi import HTTPException
import requests
from stream_fusion.services.rd_conn.token_manager import RDTokenManager
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
@ -17,8 +17,8 @@ from stream_fusion.logging_config import logger
class RealDebrid(BaseDebrid):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session: aiohttp.ClientSession = None):
super().__init__(config, session)
self.base_url = f"{settings.rd_base_url}/{settings.rd_api_version}/"
if not settings.rd_unique_account:
self.token_manager = RDTokenManager(config)
@ -47,42 +47,57 @@ class RealDebrid(BaseDebrid):
else:
return {"Authorization": f"Bearer {self.token_manager.get_access_token()}"}
def add_magnet(self, magnet, ip=None):
async def add_magnet(self, magnet, ip=None):
url = f"{self.base_url}torrents/addMagnet"
data = {"magnet": magnet}
logger.info(f"Real-Debrid: Adding magnet: {magnet}")
return self.json_response(
url, method="post", headers=self.get_headers(), data=data
)
try:
return await self.json_response(
url, method="post", headers=self.get_headers(), data=data
)
except HTTPException as e:
if e.status_code == 451:
logger.error(f"Real-Debrid: Torrent banned (451): {magnet}")
raise
raise
def add_torrent(self, torrent_file):
async def add_torrent(self, torrent_file):
url = f"{self.base_url}torrents/addTorrent"
return self.json_response(
url, method="put", headers=self.get_headers(), data=torrent_file
)
try:
return await self.json_response(
url, method="put", headers=self.get_headers(), data=torrent_file
)
except HTTPException as e:
if e.status_code == 451:
logger.error(f"Real-Debrid: Torrent banned (451)")
raise
raise
def delete_torrent(self, id):
async def delete_torrent(self, id):
url = f"{self.base_url}torrents/delete/{id}"
return self.json_response(url, method="delete", headers=self.get_headers())
return await self.json_response(url, method="delete", headers=self.get_headers())
def get_torrent_info(self, torrent_id):
async def get_torrent_info(self, torrent_id):
logger.info(f"Real-Debrid: Getting torrent info for ID: {torrent_id}")
url = f"{self.base_url}torrents/info/{torrent_id}"
torrent_info = self.json_response(url, headers=self.get_headers())
torrent_info = await self.json_response(url, headers=self.get_headers())
if not torrent_info or "files" not in torrent_info:
return None
return torrent_info
def select_files(self, torrent_id, file_id):
async def select_files(self, torrent_id, file_id):
logger.info(
f"Real-Debrid: Selecting file(s): {file_id} for torrent ID: {torrent_id}"
)
self._torrent_rate_limit()
await self._torrent_rate_limit()
url = f"{self.base_url}torrents/selectFiles/{torrent_id}"
data = {"files": str(file_id)}
requests.post(url, headers=self.get_headers(), data=data)
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=30)
async with session.post(url, headers=self.get_headers(), data=data, timeout=timeout) as response:
pass # Just need to make the request
def unrestrict_link(self, link):
async def unrestrict_link(self, link):
url = f"{self.base_url}unrestrict/link"
data = {"link": link}
max_retries = 3
@ -90,52 +105,53 @@ class RealDebrid(BaseDebrid):
for attempt in range(max_retries):
try:
response = self.json_response(url, method="post", headers=self.get_headers(), data=data)
response = await self.json_response(url, method="post", headers=self.get_headers(), data=data)
if response and "download" in response:
return response
else:
logger.warning(f"Real-Debrid: Unexpected response when unrestricting link: {response}")
except requests.RequestException as e:
except Exception as e:
if attempt < max_retries - 1:
logger.warning(f"Real-Debrid: Error unrestricting link (attempt {attempt + 1}/{max_retries}): {str(e)}")
time.sleep(retry_delay)
await asyncio.sleep(retry_delay)
else:
logger.error(f"Real-Debrid: Failed to unrestrict link after {max_retries} attempts: {str(e)}")
raise
return None
def is_already_added(self, magnet):
async def is_already_added(self, magnet):
hash = magnet.split("urn:btih:")[1].split("&")[0].lower()
url = f"{self.base_url}torrents"
torrents = self.json_response(url, headers=self.get_headers())
torrents = await self.json_response(url, headers=self.get_headers())
for torrent in torrents:
if torrent["hash"].lower() == hash:
return torrent["id"]
return False
def wait_for_link(self, torrent_id, timeout=60, interval=5):
async def wait_for_link(self, torrent_id, timeout=60, interval=5):
import time
start_time = time.time()
while time.time() - start_time < timeout:
torrent_info = self.get_torrent_info(torrent_id)
torrent_info = await self.get_torrent_info(torrent_id)
if (
torrent_info
and "links" in torrent_info
and len(torrent_info["links"]) > 0
):
return torrent_info["links"]
time.sleep(interval)
await asyncio.sleep(interval)
return None
def get_availability_bulk(self, hashes_or_magnets, ip=None):
self._torrent_rate_limit()
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
await self._torrent_rate_limit()
if len(hashes_or_magnets) == 0:
logger.info("Real-Debrid: No hashes to be sent.")
return dict()
url = f"{self.base_url}torrents/instantAvailability/{'/'.join(hashes_or_magnets)}"
return self.json_response(url, headers=self.get_headers())
return await self.json_response(url, headers=self.get_headers())
def get_stream_link(self, query, config, ip=None):
async def get_stream_link(self, query, config=None, ip=None):
# Extract query parameters
magnet = query["magnet"]
stream_type = query["type"]
@ -147,42 +163,42 @@ class RealDebrid(BaseDebrid):
logger.info(f"Real-Debrid: Getting stream link for {stream_type} with hash: {info_hash}")
# Check for cached torrents
cached_torrent_ids = self._get_cached_torrent_ids(info_hash)
cached_torrent_ids = await self._get_cached_torrent_ids(info_hash)
logger.info(f"Real-Debrid: Found {len(cached_torrent_ids)} cached torrents with hash: {info_hash}")
torrent_id = None
if cached_torrent_ids:
torrent_info = self._get_cached_torrent_info(cached_torrent_ids, file_index, season, episode, stream_type)
torrent_info = await self._get_cached_torrent_info(cached_torrent_ids, file_index, season, episode, stream_type)
if torrent_info:
torrent_id = torrent_info["id"]
logger.info(f"Real-Debrid: Found cached torrent with ID: {torrent_id}")
# If the torrent is not in cache, add it
if torrent_id is None:
torrent_id = self.add_magnet_or_torrent_and_select(query, ip)
torrent_id = await self.add_magnet_or_torrent_and_select(query, ip)
if not torrent_id:
logger.error("Real-Debrid: Failed to add or find torrent.")
raise HTTPException(status_code=500, detail="Real-Debrid: Failed to add or find torrent.")
logger.info(f"Real-Debrid: Waiting for link(s) to be ready for torrent ID: {torrent_id}")
links = self.wait_for_link(torrent_id, timeout=20) # Increased timeout to allow for slow servers
links = await self.wait_for_link(torrent_id, timeout=20)
if links is None:
logger.warning("Real-Debrid: No links available after waiting. Returning NO_CACHE_VIDEO_URL.")
return settings.no_cache_video_url
# Refresh torrent info to ensure we have the latest data
torrent_info = self.get_torrent_info(torrent_id)
torrent_info = await self.get_torrent_info(torrent_id)
# Select the appropriate link
if len(links) > 1:
logger.info("Real-Debrid: Finding appropriate link")
download_link = self._find_appropriate_link(torrent_info, links, file_index, season, episode)
download_link = await self._find_appropriate_link(torrent_info, links, file_index, season, episode)
else:
download_link = links[0]
# Unrestrict the link
logger.info(f"Real-Debrid: Unrestricting the download link: {download_link}")
unrestrict_response = self.unrestrict_link(download_link)
unrestrict_response = await self.unrestrict_link(download_link)
if not unrestrict_response or "download" not in unrestrict_response:
logger.error("Real-Debrid: Failed to unrestrict link.")
return None
@ -190,10 +206,10 @@ class RealDebrid(BaseDebrid):
logger.info(f"Real-Debrid: Got download link: {unrestrict_response['download']}")
return unrestrict_response["download"]
def _get_cached_torrent_ids(self, info_hash):
self._torrent_rate_limit()
async def _get_cached_torrent_ids(self, info_hash):
await self._torrent_rate_limit()
url = f"{self.base_url}torrents"
torrents = self.json_response(url, headers=self.get_headers())
torrents = await self.json_response(url, headers=self.get_headers())
logger.info(f"Real-Debrid: Searching user's downloads for hash: {info_hash}")
torrent_ids = [
@ -203,11 +219,11 @@ class RealDebrid(BaseDebrid):
]
return torrent_ids
def _get_cached_torrent_info(
async def _get_cached_torrent_info(
self, cached_ids, file_index, season, episode, stream_type
):
for cached_torrent_id in cached_ids:
cached_torrent_info = self.get_torrent_info(cached_torrent_id)
cached_torrent_info = await self.get_torrent_info(cached_torrent_id)
if self._torrent_contains_file(
cached_torrent_info, file_index, season, episode, stream_type
):
@ -236,37 +252,37 @@ class RealDebrid(BaseDebrid):
)
return False
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
if torrent_download is None:
logger.info("Real-Debrid: Adding magnet")
magnet_response = self.add_magnet(magnet)
magnet_response = await self.add_magnet(magnet)
logger.info(f"Real-Debrid: Add magnet response: {magnet_response}")
if not magnet_response or "id" not in magnet_response:
logger.error("Real-Debrid: Failed to add magnet.")
raise HTTPException(
status_code=500, detail="Real-Debrid: Failed to add magnet."
status_code=451, detail="Real-Debrid: Torrent banned or unavailable for legal reasons."
)
torrent_id = magnet_response["id"]
else:
logger.info("Real-Debrid: Downloading and adding torrent file")
torrent_file = self.download_torrent_file(torrent_download)
upload_response = self.add_torrent(torrent_file)
torrent_file = await self.download_torrent_file(torrent_download)
upload_response = await self.add_torrent(torrent_file)
logger.info(f"Real-Debrid: Add torrent file response: {upload_response}")
if not upload_response or "id" not in upload_response:
logger.error("Real-Debrid: Failed to add torrent file.")
raise HTTPException(
status_code=500, detail="Real-Debrid: Failed to add torrent file."
status_code=451, detail="Real-Debrid: Torrent banned or unavailable for legal reasons."
)
torrent_id = upload_response["id"]
logger.info(f"Real-Debrid: New torrent added with ID: {torrent_id}")
return self.get_torrent_info(torrent_id)
def add_magnet_or_torrent_and_select(self, query, ip=None):
return await self.get_torrent_info(torrent_id)
async def add_magnet_or_torrent_and_select(self, query, ip=None):
magnet = query['magnet']
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
stream_type = query['type']
@ -274,7 +290,7 @@ class RealDebrid(BaseDebrid):
season = query["season"]
episode = query["episode"]
torrent_info = self.add_magnet_or_torrent(magnet, torrent_download, ip)
torrent_info = await self.add_magnet_or_torrent(magnet, torrent_download, ip)
if not torrent_info or "files" not in torrent_info:
logger.error("Real-Debrid: Failed to add or find torrent.")
return None
@ -283,17 +299,17 @@ class RealDebrid(BaseDebrid):
if is_season_pack:
logger.info("Real-Debrid: Processing season pack")
self._process_season_pack(torrent_info)
await self._process_season_pack(torrent_info)
else:
logger.info("Real-Debrid: Selecting specific file")
self._select_file(
await self._select_file(
torrent_info, stream_type, file_index, season, episode
)
logger.info(f"Real-Debrid: Added magnet or torrent to download service: {magnet[:50]}")
return torrent_info['id']
def _process_season_pack(self, torrent_info):
async def _process_season_pack(self, torrent_info):
logger.info("Real-Debrid: Processing season pack files")
video_file_indexes = [
str(file["id"])
@ -302,26 +318,26 @@ class RealDebrid(BaseDebrid):
]
if video_file_indexes:
self.select_files(torrent_info["id"], ",".join(video_file_indexes))
await self.select_files(torrent_info["id"], ",".join(video_file_indexes))
logger.info(
f"Real-Debrid: Selected {len(video_file_indexes)} video files from season pack"
)
time.sleep(10)
await asyncio.sleep(10)
else:
logger.warning("Real-Debrid: No video files found in the season pack")
def _select_file(self, torrent_info, stream_type, file_index, season, episode):
async def _select_file(self, torrent_info, stream_type, file_index, season, episode):
torrent_id = torrent_info["id"]
if file_index is not None:
logger.info(f"Real-Debrid: Selecting file_index: {file_index}")
self.select_files(torrent_id, file_index)
await self.select_files(torrent_id, file_index)
return
files = torrent_info["files"]
if stream_type == "movie":
largest_file_id = max(files, key=lambda x: x["bytes"])["id"]
logger.info(f"Real-Debrid: Selecting largest file_index: {largest_file_id}")
self.select_files(torrent_id, largest_file_id)
await self.select_files(torrent_id, largest_file_id)
elif stream_type == "series":
matching_files = [
file
@ -333,17 +349,17 @@ class RealDebrid(BaseDebrid):
logger.info(
f"Real-Debrid: Selecting largest matching file_index: {largest_file_id}"
)
self.select_files(torrent_id, largest_file_id)
await self.select_files(torrent_id, largest_file_id)
else:
logger.warning(
"Real-Debrid: No matching files found for the specified episode"
)
def _find_appropriate_link(self, torrent_info, links, file_index, season, episode):
async def _find_appropriate_link(self, torrent_info, links, file_index, season, episode):
# Refresh torrent info to get the latest selected files
torrent_info = self.get_torrent_info(torrent_info["id"])
torrent_info = await self.get_torrent_info(torrent_info["id"])
selected_files = [file for file in torrent_info["files"] if file["selected"] == 1]
logger.info(f"Real-Debrid: Finding appropriate link. Selected files: {len(selected_files)}, Available links: {len(links)}")
if not selected_files:

View file

@ -0,0 +1,35 @@
from fastapi import Response
from fastapi.responses import RedirectResponse, JSONResponse
_BASE = "https://raw.githubusercontent.com/Telkaoss/stream-fusion/refs/heads/master/stream_fusion/static/videos"
_SLOTS_FULL_URL = f"{_BASE}/slots_full.mp4"
_ERROR_URL = f"{_BASE}/error.mp4"
_TORBOX_RATE_LIMIT_URL = f"{_BASE}/torbox_rate_limit.mp4"
_STATUS_VIDEO_URLS: dict[str, str] = {
"DIFF_ISSUE": _SLOTS_FULL_URL,
"STORE_LIMIT_EXCEEDED": _SLOTS_FULL_URL,
"TORBOX_RATE_LIMIT": _TORBOX_RATE_LIMIT_URL,
}
_DEFAULT_URL = _ERROR_URL
def _normalize(key: str) -> str:
return key.upper().replace("-", "_").replace(" ", "_") if key else ""
def get_status_video_url(status_keys: list, default_key: str = "UNKNOWN") -> str:
for key in status_keys:
if not key:
continue
url = _STATUS_VIDEO_URLS.get(_normalize(key))
if url:
return url
return _STATUS_VIDEO_URLS.get(_normalize(default_key), _DEFAULT_URL)
def build_status_video_response(status_keys: list, default_key: str = "UNKNOWN") -> Response:
return RedirectResponse(url=get_status_video_url(status_keys, default_key), status_code=302)

View file

@ -1,31 +1,28 @@
import requests
import time
from urllib.parse import quote
import json
import asyncio
import aiohttp
import json as json_lib
from urllib.parse import quote, unquote
from stream_fusion.logging_config import logger
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
from stream_fusion.utils.debrid.debrid_exceptions import DebridError
from stream_fusion.settings import settings
from stream_fusion.utils.general import season_episode_in_filename
from stream_fusion.utils.general import season_episode_in_filename, smart_episode_fallback, is_video_file
class StremThru(BaseDebrid):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session: aiohttp.ClientSession = None):
super().__init__(config, session)
self.config = config
self.stremthru_url = settings.stremthru_url or "https://stremthru.13377001.xyz"
self.base_url = f"{self.stremthru_url}/v0/store"
self.store_name = None
self.token = None
self.session = self._create_session()
self._headers = {}
if not self.store_name:
self.auto_detect_store()
def _create_session(self):
session = super()._create_session()
return session
def auto_detect_store(self):
"""Tente de détecter automatiquement le debrideur à utiliser en fonction des tokens disponibles"""
priority_order = [
@ -38,35 +35,33 @@ class StremThru(BaseDebrid):
("offcloud", "OCCredentials"),
("pikpak", "PPCredentials")
]
for store_name, token_key in priority_order:
token = self.config.get(token_key)
if token and len(token.strip()) > 5:
logger.info(f"StremThru: Utilisation automatique de {store_name} détecté avec le token {token_key}")
self.set_store_credentials(store_name, token)
break
if token:
# Handle both string and dict tokens
token_str = token if isinstance(token, str) else str(token)
if len(token_str.strip()) > 5:
logger.info(f"StremThru: Utilisation automatique de {store_name} détecté avec le token {token_key}")
self.set_store_credentials(store_name, token_str)
break
if not self.store_name:
logger.warning("StremThru: Aucun debrideur détecté automatiquement")
def set_store_credentials(self, store_name, token):
"""Configure les informations d'identification du store pour StremThru"""
self.store_name = store_name
self.token = token
self.session.headers["X-StremThru-Store-Name"] = store_name
self.session.headers["X-StremThru-Store-Authorization"] = f"Bearer {token}"
self.session.headers["User-Agent"] = "stream-fusion"
self._headers = {
"X-StremThru-Store-Name": store_name,
"X-StremThru-Store-Authorization": f"Bearer {token}",
"User-Agent": "stream-fusion"
}
@staticmethod
def get_underlying_debrid_code(store_name=None):
"""Retourne le code du service de debrid sous-jacent (RD, AD, TB, PM, etc.)
Args:
store_name (str, optional): Nom du store. Si None, retourne None.
Returns:
str: Code du service de debrid (RD, AD, TB, PM, etc.) ou None si non identifié
"""
"""Retourne le code du service de debrid sous-jacent (RD, AD, TB, PM, etc.)"""
debrid_codes = {
"realdebrid": "RD",
"alldebrid": "AD",
@ -77,9 +72,8 @@ class StremThru(BaseDebrid):
"easydebrid": "ED",
"pikpak": "PK",
}
return debrid_codes.get(store_name)
def parse_store_creds(self, token):
"""Parse les informations d'identification du store"""
if ":" in token:
@ -91,20 +85,25 @@ class StremThru(BaseDebrid):
"""Vérifie si l'utilisateur a un compte premium"""
try:
client_ip_param = f"&client_ip={ip}" if ip else ""
response = self.json_response(f"{self.base_url}/user?{client_ip_param}")
response = await self.json_response(
f"{self.base_url}/user?{client_ip_param}",
headers=self._headers
)
if response and "data" in response:
return response["data"]["subscription_status"] == "premium"
except Exception as e:
logger.warning(f"Exception lors de la vérification du statut premium sur StremThru-{self.store_name}: {e}")
return False
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
"""Vérifie la disponibilité des torrents avec l'API StremThru"""
if not hashes_or_magnets:
return []
results = []
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=5)
chunk_size = 50
for i in range(0, len(hashes_or_magnets), chunk_size):
chunk = hashes_or_magnets[i:i + chunk_size]
@ -119,247 +118,292 @@ class StremThru(BaseDebrid):
else:
magnet_url = hash_or_magnet
magnets.append(magnet_url)
try:
url = f"{self.base_url}/magnets/check?magnet={','.join([quote(m) for m in magnets])}"
if ip:
url += f"&client_ip={ip}"
logger.debug(f"Vérification de {len(magnets)} magnets sur StremThru-{self.store_name}")
response = self.session.get(url)
if response.status_code == 200:
try:
json_data = response.json()
if json_data and "data" in json_data and "items" in json_data["data"]:
for item in json_data["data"]["items"]:
if item.get("status") == "cached":
hash_value = item["hash"].lower()
results.append({
"hash": hash_value,
"status": "cached",
"files": item.get("files", []),
"store_name": self.store_name,
"debrid": StremThru.get_underlying_debrid_code(self.store_name)
})
logger.debug(f"Magnet caché trouvé sur StremThru-{self.store_name}: {hash_value}")
except Exception as json_e:
logger.warning(f"Erreur lors du parsing JSON: {json_e}")
async with session.get(url, headers=self._headers, timeout=timeout) as response:
if response.status == 200:
try:
json_data = await response.json()
if json_data and "data" in json_data and "items" in json_data["data"]:
for item in json_data["data"]["items"]:
if item.get("status") == "cached":
hash_value = item["hash"].lower()
results.append({
"hash": hash_value,
"status": "cached",
"files": item.get("files", []),
"store_name": self.store_name,
"debrid": StremThru.get_underlying_debrid_code(self.store_name)
})
logger.debug(f"Magnet caché trouvé sur StremThru-{self.store_name}: {hash_value}")
except Exception as json_e:
logger.warning(f"Erreur lors du parsing JSON: {json_e}")
except Exception as e:
logger.warning(f"Erreur lors de la vérification des magnets sur StremThru-{self.store_name}: {e}")
return results
def add_magnet(self, magnet, ip=None):
"""Ajoute un magnet à StremThru
Args:
magnet: URL du magnet ou hash
ip: Adresse IP du client
Returns:
dict: Informations sur le magnet ajouté ou None en cas d'erreur
"""
async def add_magnet(self, magnet, ip=None, torrent_file_content=None):
"""Ajoute un magnet à StremThru"""
try:
if not magnet.startswith('magnet:'):
magnet = f"magnet:?xt=urn:btih:{magnet}"
client_ip_param = f"?client_ip={ip}" if ip else ""
url = f"{self.base_url}/magnets{client_ip_param}"
logger.debug(f"Ajout du magnet sur StremThru-{self.store_name}: {magnet[:60]}...")
response = self.session.post(url, json={"magnet": magnet})
if response.status_code in [200, 201]:
try:
json_data = response.json()
if json_data and "data" in json_data:
logger.debug(f"Magnet ajouté avec succès sur StremThru-{self.store_name} (code: {response.status_code})")
return json_data["data"]
except Exception as json_e:
logger.warning(f"Erreur lors du parsing JSON: {json_e}")
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=30)
# PRIORITE 1: Si on a le fichier .torrent, l'envoyer en multipart/form-data
if torrent_file_content:
logger.debug(f"Ajout du fichier .torrent sur StremThru-{self.store_name}")
form_data = aiohttp.FormData()
form_data.add_field('torrent', torrent_file_content,
filename='file.torrent',
content_type='application/x-bittorrent')
async with session.post(url, data=form_data, headers=self._headers, timeout=timeout) as response:
if response.status in [200, 201]:
try:
json_data = await response.json()
if json_data and "data" in json_data:
logger.debug(f"Magnet ajouté avec succès sur StremThru-{self.store_name} (code: {response.status})")
return json_data["data"]
except Exception as json_e:
logger.warning(f"Erreur lors du parsing JSON: {json_e}")
else:
text = await response.text()
logger.error(f"Erreur lors de l'ajout du magnet: {response.status} - {text}")
else:
logger.error(f"Erreur lors de l'ajout du magnet: {response.status_code} - {response.text}")
# PRIORITE 2: Sinon utiliser le magnet link en JSON
if not magnet.startswith('magnet:'):
magnet = f"magnet:?xt=urn:btih:{magnet}"
logger.debug(f"Ajout du magnet sur StremThru-{self.store_name}: {magnet[:60]}...")
async with session.post(url, json={"magnet": magnet}, headers=self._headers, timeout=timeout) as response:
if response.status in [200, 201]:
try:
json_data = await response.json()
if json_data and "data" in json_data:
logger.debug(f"Magnet ajouté avec succès sur StremThru-{self.store_name} (code: {response.status})")
return json_data["data"]
except Exception as json_e:
logger.warning(f"Erreur lors du parsing JSON: {json_e}")
else:
text = await response.text()
logger.error(f"Erreur lors de l'ajout du magnet: {response.status} - {text}")
try:
err_json = json_lib.loads(text)
error = err_json.get("error", {})
error_code = error.get("code")
error_message = error.get("message", "")
upstream = error.get("__upstream_cause__") or error.get("__cause__") or {}
upstream_error_code = upstream.get("error") or upstream.get("code")
if "per 1 hour" in error_message or "per hour" in error_message:
error_code = "TORBOX_RATE_LIMIT"
except Exception:
error_code = None
upstream_error_code = None
error_message = ""
raise DebridError(
f"StremThru error: {response.status}",
error_code=error_code,
upstream_error_code=upstream_error_code,
)
except DebridError:
raise
except Exception as e:
logger.warning(f"Erreur lors de l'ajout du magnet sur StremThru-{self.store_name}: {e}")
return None
def get_magnet_info(self, magnet_info, ip=None):
"""Récupère les informations d'un magnet
Args:
magnet_info: ID du magnet ou dictionnaire contenant déjà les informations du magnet
ip: Adresse IP du client
Returns:
dict: Informations sur le magnet ou None en cas d'erreur
"""
async def get_magnet_info(self, magnet_info, ip=None):
"""Récupère les informations d'un magnet"""
if isinstance(magnet_info, dict):
if "files" in magnet_info and "id" in magnet_info:
logger.debug(f"Utilisation des informations de magnet déjà disponibles pour {magnet_info.get('id')}")
return magnet_info
magnet_id = magnet_info.get("id")
if not magnet_id:
logger.error("Aucun ID de magnet trouvé dans les informations fournies")
return None
else:
magnet_id = magnet_info
try:
client_ip_param = f"?client_ip={ip}" if ip else ""
url = f"{self.base_url}/magnets/{magnet_id}{client_ip_param}"
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=30)
logger.debug(f"Récupération des informations du magnet {magnet_id} sur StremThru-{self.store_name}")
response = self.session.get(url)
if response.status_code in [200, 201]:
try:
json_data = response.json()
if json_data and "data" in json_data:
logger.debug(f"Informations du magnet {magnet_id} récupérées avec succès")
return json_data["data"]
except Exception as json_e:
logger.warning(f"Erreur lors du parsing JSON: {json_e}")
else:
logger.error(f"Erreur lors de la récupération du magnet: {response.status_code} - {response.text}")
if isinstance(magnet_info, dict) and "files" in magnet_info:
logger.debug("Utilisation des informations de fichiers déjà disponibles dans le magnet")
return magnet_info
async with session.get(url, headers=self._headers, timeout=timeout) as response:
if response.status in [200, 201]:
try:
json_data = await response.json()
if json_data and "data" in json_data:
logger.debug(f"Informations du magnet {magnet_id} récupérées avec succès")
return json_data["data"]
except Exception as json_e:
logger.warning(f"Erreur lors du parsing JSON: {json_e}")
else:
text = await response.text()
logger.error(f"Erreur lors de la récupération du magnet: {response.status} - {text}")
if isinstance(magnet_info, dict) and "files" in magnet_info:
logger.debug("Utilisation des informations de fichiers déjà disponibles dans le magnet")
return magnet_info
except Exception as e:
logger.warning(f"Erreur lors de la récupération du magnet {magnet_id}: {e}")
if isinstance(magnet_info, dict) and "files" in magnet_info:
logger.debug("Utilisation des informations de fichiers déjà disponibles dans le magnet après erreur")
return magnet_info
return magnet_info
return None
def get_stream_link(self, query, config=None, ip=None):
"""Génère un lien de streaming à partir d'une requête
Args:
query: Dictionnaire contenant les informations de la requête
config: Configuration de l'application
ip: Adresse IP du client
Returns:
str: URL du stream ou None en cas d'erreur
"""
async def get_stream_link(self, query, config=None, ip=None):
"""Génère un lien de streaming à partir d'une requête"""
try:
logger.debug(f"StremThru: Génération d'un lien de streaming pour {query}")
if not self.store_name:
self.auto_detect_store()
if not self.store_name:
logger.error("StremThru: Aucun debrideur configuré pour StremThru")
return None
stream_type = query.get('type')
if not stream_type:
logger.error("StremThru: Le type de média n'est pas défini dans la requête")
return None
season = query.get("season")
episode = query.get("episode")
magnet_url = query.get("magnet")
info_hash = query.get("infoHash")
file_idx = query.get("file_index", query.get("fileIdx", -1))
if magnet_url and not info_hash:
import re
hash_match = re.search(r'btih:([a-fA-F0-9]+)', magnet_url)
if hash_match:
info_hash = hash_match.group(1).lower()
logger.debug(f"StremThru: Hash extrait du magnet: {info_hash}")
if not info_hash:
logger.error("StremThru: Aucun hash trouvé dans la requête")
return None
service = query.get("service")
if service and service != "ST":
logger.debug(f"StremThru: Utilisation du service {service} spécifié dans la requête")
magnet = magnet_url or f"magnet:?xt=urn:btih:{info_hash}"
logger.debug(f"StremThru: Ajout direct du magnet {magnet} via le store {self.store_name}")
magnet_info = self.add_magnet(magnet, ip)
if not magnet_info:
logger.error(f"StremThru: Impossible d'ajouter le magnet {info_hash}")
return None
logger.debug(f"StremThru: Utilisation des informations du magnet")
magnet_data = magnet_info
if not magnet_data or "files" not in magnet_data:
magnet_id = magnet_info.get("id")
if magnet_id:
logger.debug(f"StremThru: Récupération des informations du magnet {magnet_id}")
magnet_data = self.get_magnet_info(magnet_info, ip)
if not magnet_data:
logger.error(f"StremThru: Impossible de récupérer les informations du magnet")
# Check cache first (no rate limit) to avoid createtorrent for cached torrents
magnet_data = None
cached_files = await self.get_availability_bulk([info_hash], ip)
if cached_files:
files = cached_files[0].get("files", [])
if files and any(f.get("link") for f in files):
logger.info(f"StremThru: Torrent {info_hash} en cache, bypass createtorrent")
magnet_data = {"files": files, "id": info_hash, "status": "cached"}
if not magnet_data:
logger.debug(f"StremThru: Ajout du magnet {magnet} via le store {self.store_name}")
magnet_info = await self.add_magnet(magnet, ip)
if not magnet_info:
logger.error(f"StremThru: Impossible d'ajouter le magnet {info_hash}")
return None
magnet_data = magnet_info
if not magnet_data or "files" not in magnet_data:
magnet_id = magnet_info.get("id")
if magnet_id:
logger.debug(f"StremThru: Récupération des informations du magnet {magnet_id}")
magnet_data = await self.get_magnet_info(magnet_info, ip)
if not magnet_data:
logger.error(f"StremThru: Impossible de récupérer les informations du magnet")
return None
if "files" not in magnet_data:
logger.error(f"StremThru: Aucun fichier dans le magnet {magnet_data.get('id', 'inconnu')}")
return None
target_file = None
# Pour les séries, chercher d'abord par nom
if stream_type == "series" and season and episode:
try:
numeric_season = int(season.replace("S", ""))
numeric_episode = int(episode.replace("E", ""))
for file in magnet_data["files"]:
file_name = file.get("name", "").lower()
if not any(ext in file_name for ext in [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"]):
continue
if season_episode_in_filename(file_name, numeric_season, numeric_episode):
target_file = file
logger.info(f"StremThru: Fichier trouvé par NOM: {file_name} (index: {file.get('index')})")
break
except Exception as e:
logger.warning(f"StremThru: Erreur lors de la recherche par nom: {str(e)}")
if not target_file and file_idx is not None:
target_file = next((f for f in magnet_data["files"] if f.get("index") == file_idx), None)
if target_file:
logger.info(f"StremThru: Fichier trouvé par INDEX {file_idx}: {target_file.get('name')}")
if stream_type == "movie":
file_name = target_file.get("name", "").lower()
if any(ext in file_name for ext in [".nfo", ".txt", ".jpg", ".png", ".srt", ".sub"]):
logger.warning(f"StremThru: Le fichier à l'index {file_idx} n'est pas une vidéo: {target_file.get('name')}")
target_file = None
# Try smart fallback for series before falling back to largest file
if not target_file and stream_type == "series" and season and episode:
try:
video_files = [f for f in magnet_data["files"] if is_video_file(f.get("name", ""))]
if video_files:
numeric_season = int(season.replace("S", ""))
numeric_episode = int(episode.replace("E", ""))
fallback_file = smart_episode_fallback(video_files, numeric_season, numeric_episode)
if fallback_file:
target_file = fallback_file
logger.info(f"StremThru: Fichier trouvé par FALLBACK INTELLIGENT: {target_file.get('name')} (index: {target_file.get('index')})")
except Exception as e:
logger.warning(f"StremThru: Erreur lors du fallback intelligent: {str(e)}")
if not target_file:
logger.debug(f"StremThru: Aucun fichier trouvé par nom ou index, recherche du plus gros fichier")
video_files = []
for file in magnet_data["files"]:
file_name = file.get("name", "").lower()
if any(ext in file_name for ext in [".nfo", ".txt", ".jpg", ".png", ".srt", ".sub"]):
logger.debug(f"StremThru: Ignoré le fichier non-vidéo: {file_name}")
continue
if any(ext in file_name for ext in [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"]):
video_files.append(file)
if video_files:
target_file = sorted(video_files, key=lambda x: x.get("size", 0), reverse=True)[0]
logger.info(f"StremThru: Sélection du plus gros fichier vidéo: {target_file.get('name')} (index: {target_file.get('index')})")
@ -370,75 +414,144 @@ class StremThru(BaseDebrid):
else:
logger.error("StremThru: Aucun fichier trouvé dans le torrent")
return None
if not target_file or "link" not in target_file:
logger.error(f"StremThru: Fichier cible non trouvé ou sans lien")
return None
torrent_id = magnet_info.get("id", "")
file_id = target_file.get("index", "")
if stream_type == "series" and season and episode:
logger.info(f"StremThru: Sélection finale de S{season}E{episode} dans le torrent {torrent_id}, fichier: {target_file.get('name')} (index: {file_id})")
else:
logger.info(f"StremThru: Sélection finale du fichier {target_file.get('name')} (index: {file_id}) dans le torrent {torrent_id}")
client_ip_param = f"?client_ip={ip}" if ip else ""
url = f"{self.base_url}/link/generate{client_ip_param}"
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=30)
logger.debug(f"StremThru: Génération du lien pour {target_file.get('name')}")
# Pour TorBox: appel direct requestdl pour éviter le CDN ceur de StremThru (down)
if self.store_name == "torbox" and target_file["link"].startswith("stremthru://store/torbox/"):
try:
import base64 as _b64
link_token = target_file["link"].split("stremthru://store/torbox/")[1]
decoded = _b64.b64decode(link_token + "==").decode()
tb_torrent_id, tb_file_id = decoded.split(":")
tb_token = self.token
tb_url = f"https://api.torbox.app/v1/api/torrents/requestdl?token={tb_token}&torrent_id={tb_torrent_id}&file_id={tb_file_id}&zip_link=false"
logger.info(f"StremThru-torbox: Appel direct requestdl (bypass CDN ceur)")
async with session.get(tb_url, timeout=timeout) as tb_resp:
if tb_resp.status == 200:
tb_data = await tb_resp.json()
if tb_data.get("success") and tb_data.get("data"):
stream_link = tb_data["data"]
logger.info(f"StremThru-torbox: Lien direct généré: {stream_link}")
return stream_link
logger.warning(f"StremThru-torbox: requestdl échoué, fallback StremThru")
except Exception as e:
logger.warning(f"StremThru-torbox: Erreur requestdl: {e}, fallback StremThru")
json_data = {"link": target_file["link"]}
try:
response = self.session.post(url, json=json_data)
if response.status_code in [200, 201]:
try:
json_data = response.json()
if json_data and "data" in json_data and "link" in json_data["data"]:
stream_link = json_data["data"]["link"]
logger.info(f"StremThru: Lien de streaming généré avec succès: {stream_link}")
return stream_link
except json.JSONDecodeError:
stream_link = response.text.strip()
if stream_link.startswith(('http://', 'https://')):
logger.info(f"StremThru: Lien de streaming reçu directement: {stream_link}")
return stream_link
else:
logger.error(f"StremThru: Réponse non-JSON invalide: {stream_link[:100]}...")
except Exception as e:
logger.error(f"StremThru: Erreur lors du traitement de la réponse: {str(e)}")
else:
logger.error(f"StremThru: Échec de la génération du lien de streaming: {response.status_code} - {response.text[:100]}...")
async with session.post(url, json=json_data, headers=self._headers, timeout=timeout) as response:
if response.status in [200, 201]:
try:
resp_data = await response.json()
if resp_data and "data" in resp_data and "link" in resp_data["data"]:
stream_link = resp_data["data"]["link"]
logger.info(f"StremThru: Lien de streaming généré avec succès: {stream_link}")
return stream_link
except Exception:
stream_link = (await response.text()).strip()
if stream_link.startswith(('http://', 'https://')):
logger.info(f"StremThru: Lien de streaming reçu directement: {stream_link}")
return stream_link
else:
logger.error(f"StremThru: Réponse non-JSON invalide: {stream_link[:100]}...")
else:
text = await response.text()
logger.error(f"StremThru: Échec de la génération du lien de streaming: {response.status} - {text[:100]}...")
except Exception as e:
logger.error(f"StremThru: Erreur lors de la génération du lien: {str(e)}")
return None
except DebridError:
raise
except Exception as e:
logger.warning(f"Erreur lors de la génération du lien sur StremThru-{self.store_name}: {e}")
return None
def start_background_caching(self, magnet, query=None):
async def start_background_caching(self, magnet, query=None):
"""Démarre le téléchargement d'un magnet en arrière-plan."""
logger.info(f"Démarrage du téléchargement en arrière-plan pour un magnet via StremThru-{self.store_name}")
try:
result = self.add_magnet(magnet)
torrent_file_content = None
# Try to download and use .torrent file if available
# Exception: TorBox ignores seed=3 for .torrent uploads, always use magnet
if self.store_name == "torbox":
logger.info(f"StremThru-torbox: Using magnet only (TorBox ignores seed parameter with .torrent files)")
elif query and query.get("torrent_download"):
torrent_download = query["torrent_download"]
torrent_download = unquote(torrent_download)
# Skip if it's a magnet link (some indexers return magnet as link)
if not torrent_download.startswith("magnet:"):
logger.info(f"Tentative de téléchargement du fichier .torrent pour StremThru-{self.store_name}: {torrent_download[:100]}")
try:
session = await self._get_session()
timeout = aiohttp.ClientTimeout(total=10)
async with session.get(torrent_download, timeout=timeout) as response:
if response.status == 200:
content = await response.read()
# Validate that it's actually a .torrent file (bencoded, starts with 'd')
if content and len(content) > 0 and content[0:1] == b'd':
torrent_file_content = content
logger.info(f"Fichier .torrent téléchargé et validé avec succès pour StremThru-{self.store_name}")
else:
logger.warning(f"Le contenu téléchargé n'est pas un fichier .torrent valide pour StremThru-{self.store_name}, fallback sur magnet")
else:
logger.warning(f"Impossible de télécharger le fichier .torrent pour StremThru-{self.store_name}: {response.status}")
except Exception as e:
logger.warning(f"Erreur lors du téléchargement du .torrent pour StremThru-{self.store_name}: {str(e)}, fallback sur magnet")
# Add magnet (with .torrent file if available, or just magnet)
result = None
if torrent_file_content:
try:
logger.debug(f"Tentative d'ajout avec le fichier .torrent pour StremThru-{self.store_name}")
result = await self.add_magnet(magnet, torrent_file_content=torrent_file_content)
except Exception as e:
logger.warning(f"Échec de l'ajout du fichier .torrent pour StremThru-{self.store_name}: {str(e)}, fallback sur magnet")
result = None
# Fallback to magnet if .torrent failed or wasn't available
if not result:
logger.info(f"Utilisation du magnet pour StremThru-{self.store_name}")
result = await self.add_magnet(magnet, torrent_file_content=None)
if not result:
logger.error(f"Échec du démarrage du téléchargement en arrière-plan via StremThru-{self.store_name}")
return False
magnet_id = result.get("id")
if not magnet_id:
logger.error(f"Aucun ID de magnet retourné par StremThru-{self.store_name}")
return False
logger.info(f"Téléchargement en arrière-plan démarré avec succès via StremThru-{self.store_name}, ID: {magnet_id}")
return True
except DebridError:
raise
except Exception as e:
logger.error(f"Erreur lors du démarrage du téléchargement en arrière-plan via StremThru-{self.store_name}: {str(e)}")
return False

View file

@ -1,7 +1,7 @@
from itertools import islice
import uuid
import tenacity
from urllib.parse import unquote
import asyncio
import aiohttp
from fastapi import HTTPException
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
@ -9,9 +9,10 @@ from stream_fusion.utils.general import get_info_hash_from_magnet, season_episod
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class Torbox(BaseDebrid):
def __init__(self, config):
super().__init__(config)
def __init__(self, config, session: aiohttp.ClientSession = None):
super().__init__(config, session)
self.base_url = f"{settings.tb_base_url}/{settings.tb_api_version}/api"
self.token = settings.tb_token if settings.tb_unique_account else self.config["TBToken"]
logger.info(f"Torbox: Initialized with base URL: {self.base_url}")
@ -26,27 +27,27 @@ class Torbox(BaseDebrid):
return {"Authorization": f"Bearer {settings.tb_token}"}
else:
logger.warning("TorBox: Unique account enabled, but no token provided. Please provide a token in the env.")
raise HTTPException(status_code=500, detail="AllDebrid token is not provided.")
raise HTTPException(status_code=500, detail="TorBox token is not provided.")
else:
return {"Authorization": f"Bearer {self.config["TBToken"]}"}
return {"Authorization": f"Bearer {self.config['TBToken']}"}
def add_magnet(self, magnet, ip=None, privacy="private"):
async def add_magnet(self, magnet, ip=None, privacy="private"):
logger.info(f"Torbox: Adding magnet: {magnet[:50]}...")
url = f"{self.base_url}/torrents/createtorrent"
seed = 2 if privacy == "private" else 1
seed = 3
data = {
"magnet": magnet,
"seed": seed,
"allow_zip": "false"
}
response = self.json_response(url, method='post', headers=self.get_headers(), data=data)
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data, retry_on_429=False)
logger.info(f"Torbox: Add magnet response: {response}")
return response
def add_torrent(self, torrent_file, privacy="private"):
async def add_torrent(self, torrent_file, privacy="private"):
logger.info("Torbox: Adding torrent file")
url = f"{self.base_url}/torrents/createtorrent"
seed = 2 if privacy == "private" else 1
seed = 3
data = {
"seed": seed,
"allow_zip": "false"
@ -54,87 +55,105 @@ class Torbox(BaseDebrid):
files = {
"file": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')
}
response = self.json_response(url, method='post', headers=self.get_headers(), data=data, files=files)
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data, files=files, retry_on_429=False)
logger.info(f"Torbox: Add torrent file response: {response}")
return response
def get_torrent_info(self, torrent_id):
async def get_torrent_info(self, torrent_id):
logger.info(f"Torbox: Getting info for torrent ID: {torrent_id}")
url = f"{self.base_url}/torrents/mylist?bypass_cache=true&id={torrent_id}"
response = self.json_response(url, headers=self.get_headers())
response = await self.json_response(url, headers=self.get_headers())
logger.debug(f"Torbox: Torrent info response: {response}")
return response
def control_torrent(self, torrent_id, operation):
async def control_torrent(self, torrent_id, operation):
logger.info(f"Torbox: Controlling torrent ID: {torrent_id}, operation: {operation}")
url = f"{self.base_url}/torrents/controltorrent"
data = {
"torrent_id": torrent_id,
"operation": operation
}
response = self.json_response(url, method='post', headers=self.get_headers(), data=data)
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data)
logger.info(f"Torbox: Control torrent response: {response}")
return response
@tenacity.retry(
stop=tenacity.stop_after_attempt(3),
wait=tenacity.wait_fixed(2),
retry=tenacity.retry_if_exception_type((HTTPException, TimeoutError))
)
def request_download_link(self, torrent_id, file_id=None, zip_link=False):
async def request_download_link(self, torrent_id, file_id=None, zip_link=False):
"""Request download link with retry logic"""
logger.info(f"Torbox: Requesting download link for torrent ID: {torrent_id}, file ID: {file_id}, zip link: {zip_link}")
url = f"{self.base_url}/torrents/requestdl?token={self.token}&torrent_id={torrent_id}&file_id={file_id}&zip_link={str(zip_link).lower()}"
logger.info(f"Torbox: Requesting URL: {url}")
response = self.json_response(url, headers=self.get_headers())
logger.info(f"Torbox: Request download link response: {response}")
return response
def get_stream_link(self, query, config, ip=None):
max_attempts = 3
for attempt in range(max_attempts):
try:
response = await self.json_response(url, headers=self.get_headers())
logger.info(f"Torbox: Request download link response: {response}")
return response
except (HTTPException, asyncio.TimeoutError) as e:
if attempt < max_attempts - 1:
logger.warning(f"Torbox: Retry {attempt + 1}/{max_attempts} for download link request")
await asyncio.sleep(2)
else:
raise
return None
async def get_stream_link(self, query, config=None, ip=None):
magnet = query['magnet']
stream_type = query['type']
file_index = int(query['file_index']) if query['file_index'] is not None else None
season = query['season']
episode = query['episode']
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
torrent_download = query["torrent_download"]
if torrent_download:
from urllib.parse import unquote
torrent_download = unquote(torrent_download)
info_hash = get_info_hash_from_magnet(magnet)
logger.info(f"Torbox: Getting stream link for {stream_type} with hash: {info_hash}")
# Check if the torrent is already added
existing_torrent = self._find_existing_torrent(info_hash)
existing_torrent = await self._find_existing_torrent(info_hash)
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.")
torrent_id = existing_torrent["id"]
# Get full torrent info with files
torrent_response = await self.get_torrent_info(torrent_id)
if not torrent_response or "data" not in torrent_response:
logger.error("Torbox: Failed to get torrent info.")
return None
torrent_id = torrent_info["id"]
torrent_info = torrent_response["data"]
else:
# Add the magnet or torrent file
torrent_info = self.add_magnet_or_torrent(magnet, torrent_download)
if not torrent_info or "torrent_id" not in torrent_info:
add_response = await self.add_magnet_or_torrent(magnet, torrent_download)
if not add_response or "torrent_id" not in add_response:
logger.error("Torbox: Failed to add or find torrent.")
return None
torrent_id = torrent_info["torrent_id"]
torrent_id = add_response["torrent_id"]
# Get full torrent info with files
torrent_response = await self.get_torrent_info(torrent_id)
if not torrent_response or "data" not in torrent_response:
logger.error("Torbox: Failed to get torrent info.")
return None
torrent_info = torrent_response["data"]
logger.info(f"Torbox: Working with torrent ID: {torrent_id}")
# Wait for the torrent to be ready
if not self._wait_for_torrent_completion(torrent_id):
if not await self._wait_for_torrent_completion(torrent_id):
logger.warning("Torbox: Torrent not ready, caching in progress.")
return settings.no_cache_video_url
# Select the appropriate file
file_id = self._select_file(torrent_info, stream_type, file_index, season, episode)
if file_id == None:
if file_id is None:
logger.error("Torbox: No matching file found.")
return settings.no_cache_video_url
# Request the download link
download_link_response = self.request_download_link(torrent_id, file_id)
download_link_response = await self.request_download_link(torrent_id, file_id)
if not download_link_response or "data" not in download_link_response:
logger.error("Torbox: Failed to get download link.")
return settings.no_cache_video_url
@ -142,24 +161,23 @@ class Torbox(BaseDebrid):
logger.info(f"Torbox: Got download link: {download_link_response['data']}")
return download_link_response['data']
def get_availability_bulk(self, hashes_or_magnets, ip=None):
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
logger.info(f"Torbox: Checking availability for {len(hashes_or_magnets)} hashes/magnets")
all_results = []
for i in range(0, len(hashes_or_magnets), 50):
batch = list(islice(hashes_or_magnets, i, i + 50))
logger.info(f"Torbox: Checking batch of {len(batch)} hashes/magnets (batch {i//50 + 1})")
url = f"{self.base_url}/torrents/checkcached?hash={','.join(batch)}&format=list&list_files=true"
logger.trace(f"Torbox: Requesting URL: {url}")
response = self.json_response(url, headers=self.get_headers())
response = await self.json_response(url, headers=self.get_headers())
if response and response.get("success") and response["data"]:
all_results.extend(response["data"])
else:
logger.debug(f"Torbox: No cached avaibility for batch {i//50 + 1}")
logger.debug(f"Torbox: No cached availability for batch {i//50 + 1}")
return None
logger.info(f"Torbox: Availability check completed for all {len(hashes_or_magnets)} hashes/magnets")
return {
"success": True,
@ -167,9 +185,9 @@ class Torbox(BaseDebrid):
"data": all_results
}
def _find_existing_torrent(self, info_hash):
async def _find_existing_torrent(self, info_hash):
logger.info(f"Torbox: Searching for existing torrent with hash: {info_hash}")
torrents = self.json_response(f"{self.base_url}/torrents/mylist", headers=self.get_headers())
torrents = await self.json_response(f"{self.base_url}/torrents/mylist", headers=self.get_headers())
if torrents and "data" in torrents:
for torrent in torrents["data"]:
if torrent["hash"].lower() == info_hash.lower():
@ -178,14 +196,10 @@ class Torbox(BaseDebrid):
logger.info("Torbox: No existing torrent found")
return None
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None, privacy="private"):
if torrent_download is None:
logger.info("Torbox: Adding magnet")
response = self.add_magnet(magnet, ip, privacy)
else:
logger.info("Torbox: Downloading and adding torrent file")
torrent_file = self.download_torrent_file(torrent_download)
response = self.add_torrent(torrent_file, privacy)
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None, privacy="private"):
# Always use magnet: TorBox ignores seed=3 with .torrent file uploads
logger.info("Torbox: Adding magnet (ignoring .torrent to preserve seed settings)")
response = await self.add_magnet(magnet, ip, privacy)
logger.info(f"Torbox: Add torrent response: {response}")
@ -195,17 +209,18 @@ class Torbox(BaseDebrid):
return response["data"]
def _wait_for_torrent_completion(self, torrent_id, timeout=60, interval=10):
async def _wait_for_torrent_completion(self, torrent_id, timeout=60, interval=10):
logger.info(f"Torbox: Waiting for torrent completion, ID: {torrent_id}")
def check_status():
torrent_info = self.get_torrent_info(torrent_id)
async def check_status():
torrent_info = await self.get_torrent_info(torrent_id)
if torrent_info and "data" in torrent_info:
files = torrent_info["data"].get("files", [])
logger.info(f"Torbox: Current torrent status: {torrent_info['data']['download_state']}")
return True if len(files) > 0 else False
return False
result = self.wait_for_ready_status(check_status, timeout, interval)
result = await self.wait_for_ready_status(check_status, timeout, interval)
if result:
logger.info("Torbox: Torrent is ready")
else:
@ -215,7 +230,7 @@ class Torbox(BaseDebrid):
def _select_file(self, torrent_info, stream_type, file_index, season, episode):
logger.info(f"Torbox: Selecting file for {stream_type}, file_index: {file_index}, season: {season}, episode: {episode}")
files = torrent_info.get("files", [])
if stream_type == "movie":
if file_index is not None:
logger.info(f"Torbox: Selected file index {file_index} for movie")
@ -223,12 +238,11 @@ class Torbox(BaseDebrid):
largest_file = max(files, key=lambda x: x["size"])
logger.info(f"Torbox: Selected largest file (ID: {largest_file['id']}, Size: {largest_file['size']}) for movie")
return largest_file["id"]
elif stream_type == "series":
if file_index is not None:
logger.info(f"Torbox: Selected file index {file_index} for series")
return file_index
try:
numeric_season = int(season.replace("S", ""))
@ -236,33 +250,56 @@ class Torbox(BaseDebrid):
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"], numeric_season, numeric_episode) and is_video_file(file["short_name"])
]
logger.info(f"Torbox: DEBUG - Processing {len(files)} files total")
for i, file in enumerate(files):
logger.debug(f"Torbox: DEBUG - File {i+1}: {file['short_name']} (size: {file['size']}, is_video: {is_video_file(file['short_name'])})")
matching_files = []
for file in files:
if is_video_file(file["short_name"]):
logger.debug(f"Torbox: Checking video file: {file['short_name']}")
if season_episode_in_filename(file["short_name"], numeric_season, numeric_episode):
logger.info(f"Torbox: ✓ RTN match for {file['short_name']}")
matching_files.append(file)
else:
logger.debug(f"Torbox: ✗ No RTN match for {file['short_name']}")
logger.info(f"Torbox: {len(matching_files)} files found with RTN for S{numeric_season:02d}E{numeric_episode:02d}")
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")
logger.info(f"Torbox: Selected largest matching file (ID: {largest_matching_file['id']}, Name: {largest_matching_file['short_name']}, Size: {largest_matching_file['size']}) for series")
return largest_matching_file["id"]
else:
logger.warning(f"Torbox: No matching files found for S{numeric_season:02d}E{numeric_episode:02d}, trying smart fallback")
from stream_fusion.utils.general import smart_episode_fallback
fallback_files = [
{
"name": file["short_name"],
"size": file["size"],
"index": file["id"]
"index": file["id"]
}
for file in files if is_video_file(file["short_name"])
]
logger.info(f"Torbox: Calling smart fallback with {len(fallback_files)} files")
fallback_file = smart_episode_fallback(fallback_files, numeric_season, numeric_episode)
if fallback_file:
logger.info(f"Torbox: Smart fallback selected file: {fallback_file.get('name')} (ID: {fallback_file.get('index')})")
logger.info(f"Torbox: Smart fallback selected: {fallback_file.get('name')} (ID: {fallback_file.get('index')})")
return fallback_file.get('index')
else:
logger.error(f"Torbox: Smart fallback also failed for S{numeric_season:02d}E{numeric_episode:02d}")
return None
logger.info("Torbox: Smart fallback found nothing, trying final fallback for single file")
video_files = [f for f in files if is_video_file(f["short_name"])]
logger.debug(f"Torbox: DEBUG - Found {len(video_files)} video files in final fallback")
if len(video_files) == 1:
single_file = video_files[0]
logger.info(f"Torbox: Single video file detected, using: {single_file['short_name']} (ID: {single_file['id']})")
return single_file["id"]
else:
logger.error(f"Torbox: Smart fallback also failed for S{numeric_season:02d}E{numeric_episode:02d}")
logger.error(f"Torbox: Found {len(video_files)} video files, expected exactly 1 for single file fallback")
return None

View file

@ -31,7 +31,7 @@ class LanguagePriorityFilter(BaseFilter):
# Groupe 1 (priorité la plus élevée)
1: ["VFQ", "VF2", "VQ"], # VFQ et VF2 en priorité absolue
# Groupe 2 (priorité secondaire)
2: ["VFF", "VOF", "VFI", "FRENCH"],
2: ["VFF", "VOF", "VFI", "FRENCH", "MULTI"],
# Groupe 3 (priorité basse)
3: ["VOSTFR"],
@ -41,7 +41,7 @@ class LanguagePriorityFilter(BaseFilter):
# Configuration standard si VFQ n'est pas explicitement sélectionné
self.language_priority_groups = {
# Groupe 1 (priorité la plus élevée)
1: ["VFF", "VOF", "VFI"],
1: ["VFF", "VOF", "VFI", "MULTI"],
# Groupe 2 (priorité moyenne)
2: ["VF2", "VFQ", "VQ", "FRENCH"],
# Groupe 3 (priorité basse)

View file

@ -13,26 +13,78 @@ from stream_fusion.logging_config import logger
quality_order = {"2160p": 0, "1080p": 1, "720p": 2, "480p": 3}
hdr_order = {"DV": 0, "HDR10+": 1, "HDR10": 2, "HDR": 3}
def get_hdr_priority(hdr_list):
"""Retourne la priorité HDR (plus petit = meilleur). DV > HDR10+ > HDR10 > HDR > SDR"""
if not hdr_list:
return 99 # SDR
best = 99
for h in hdr_list:
if h in hdr_order:
best = min(best, hdr_order[h])
return best
def sort_quality(item: TorrentItem):
"""Retourne (resolution_priority, is_unknown) pour le tri."""
logger.trace(f"Filters: Evaluating quality for item: {item.raw_title}")
if not item.parsed_data.resolution:
# Ensure parsed_data is valid before accessing it
if hasattr(item, '_ensure_parsed_data_valid'):
item._ensure_parsed_data_valid()
# Check if parsed_data exists and is valid
if not item.parsed_data or not hasattr(item.parsed_data, 'resolution'):
return float("inf"), True
resolution = item.parsed_data.resolution
priority = quality_order.get(resolution, float("inf"))
return priority, item.parsed_data.resolution is None
def get_item_hdr_priority(item: TorrentItem):
"""Retourne la priorité HDR d'un item."""
if not item.parsed_data or not hasattr(item.parsed_data, 'hdr'):
return 99
return get_hdr_priority(getattr(item.parsed_data, 'hdr', []))
def get_indexer_priority_for_sort(indexer, config=None):
"""Fonction pour obtenir la priorité de l'indexer lors du tri"""
is_torbox = config and (config.get("debridDownloader") == "TorBox" or "TorBox" in config.get("service", []))
if is_torbox:
indexer_priority = {
"C411": 1, # C411/Torr9 prioritaires pour TorBox
"Torr9": 1,
"Yggtorrent": 2,
"DMM": 3,
"Public": 4,
"Sharewood": 5,
"Jackett": 6,
}
else:
indexer_priority = {
"Yggtorrent": 1, # Yggtorrent prioritaire pour les autres debrid
"DMM": 2,
"Public": 3,
"Sharewood": 4,
"C411": 5,
"Torr9": 5,
"Jackett": 6,
}
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} (TorBox={is_torbox})")
return priority
def items_sort(items, config):
logger.info(f"Filters: Sorting items by method: {config['sort']}")
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, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
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, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
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, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
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, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
else:
logger.warning(
f"Filters: Unrecognized sort method: {config['sort']}. No sorting applied."
@ -40,7 +92,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/Size first, YggFlix priority at equal quality, seeders as tiebreaker. Number of sorted items: {len(sorted_items)}"
)
return sorted_items
@ -79,6 +131,11 @@ def filter_out_non_matching_series(items, season, episode):
)
for item in items:
# Ensure parsed_data is valid before accessing it
if not item.parsed_data or not hasattr(item.parsed_data, 'seasons') or not hasattr(item.parsed_data, 'episodes'):
logger.trace(f"Filters: Skipping item with invalid parsed_data: {item.raw_title}")
continue
if len(item.parsed_data.seasons) == 0 and len(item.parsed_data.episodes) == 0:
if integrale_pattern.search(item.raw_title):
logger.trace(
@ -154,10 +211,21 @@ def remove_non_matching_title(items, titles):
return subset_index == len(subset_words)
for item in items:
cleaned_item_title = integrale_pattern.sub(
"", item.parsed_data.parsed_title
).strip()
# Ensure parsed_data is valid before accessing it
if hasattr(item, '_ensure_parsed_data_valid'):
item._ensure_parsed_data_valid()
# If parsed_data is None or invalid, use raw_title as fallback
if item.parsed_data and hasattr(item.parsed_data, 'parsed_title'):
cleaned_item_title = integrale_pattern.sub(
"", item.parsed_data.parsed_title
).strip()
else:
# Fallback to raw_title if parsed_data is invalid
cleaned_item_title = integrale_pattern.sub(
"", item.raw_title
).strip()
if item.indexer and "Yggtorrent" in item.indexer:
logger.debug(f"Filters: YggFlix item detected, accepting: {cleaned_item_title}")
filtered_items.append(item)
@ -197,16 +265,20 @@ def remove_non_matching_title(items, titles):
return filtered_items
def filter_items(items, media, config):
def filter_items(items, media, config, skip_resolution=False):
logger.info(f"Filters: Starting item filtering for media: {media.titles[0]}")
# Préparer les filtres (SANS le filtre de résolution si skip_resolution=True)
filters = {
"languages": LanguageFilter(config),
"maxSize": MaxSizeFilter(config, media.type),
"exclusionKeywords": TitleExclusionFilter(config),
"exclusion": QualityExclusionFilter(config),
# "resultsPerQuality": ResultsPerQualityFilter(config),
}
# Ajouter le filtre de résolution seulement si skip_resolution=False
if not skip_resolution:
filters["exclusion"] = QualityExclusionFilter(config)
language_priority_filter = LanguagePriorityFilter(config)
logger.info(f"Filters: Initial item count: {len(items)}")
@ -285,26 +357,15 @@ def merge_items(
)
merged_dict = {}
indexer_priority = {
"YggFlix": 1,
"DMM": 2,
"Sharewood": 3,
"Jackett": 4,
}
def get_indexer_priority(indexer):
indexer_name = indexer.split(' ')[0] if indexer and ' ' in indexer else indexer
return indexer_priority.get(indexer_name, 999)
def add_to_merged(item: TorrentItem):
key = (item.raw_title, item.size)
key = (item.raw_title, item.size, item.privacy)
if key not in merged_dict:
merged_dict[key] = item
else:
existing_priority = get_indexer_priority(merged_dict[key].indexer)
new_priority = get_indexer_priority(item.indexer)
if new_priority < existing_priority or (new_priority == existing_priority and item.seeders > merged_dict[key].seeders):
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 or 0) > (merged_dict[key].seeders or 0)):
merged_dict[key] = item
for item in cache_items:

View file

@ -28,36 +28,45 @@ def smart_episode_fallback(files: List[Dict], season: int, episode: int) -> Opti
"""
if not files:
return None
video_files = [f for f in files if is_video_file(f.get("name", ""))]
if not video_files:
return None
logger.debug(f"Smart fallback: Recherche S{season:02d}E{episode:02d} parmi {len(video_files)} fichiers")
# Only use safe patterns that include season verification
episode_patterns = [
rf"[Ss]{season:02d}[Ee]{episode:02d}", # S01E01
rf"[Ss]{season}[Ee]{episode:02d}", # S1E01
rf"[Ss]{season}[Ee]{episode:02d}", # S1E01
rf"{season:02d}x{episode:02d}", # 01x01
rf"{season}x{episode:02d}", # 1x01
rf"[Ee]{episode:02d}", # E01 (si saison unique)
rf"[Ee]pisode.{episode:02d}", # Episode 01
rf"\.{episode:02d}\.", # .01.
]
for pattern in episode_patterns:
for file in video_files:
filename = file.get("name", "")
if re.search(pattern, filename, re.IGNORECASE):
logger.debug(f"Smart fallback: Match trouvé avec pattern '{pattern}': {filename}")
return file
sorted_files = sorted(video_files, key=lambda f: f.get("name", "").lower())
# Deduplicate files by name (StremThru can return duplicates)
seen_names = set()
unique_files = []
for f in video_files:
name = f.get("name", "")
if name not in seen_names:
seen_names.add(name)
unique_files.append(f)
logger.info(f"Smart fallback: Total: {len(video_files)} fichiers, Uniques: {len(unique_files)} fichiers")
sorted_files = sorted(unique_files, key=lambda f: f.get("name", "").lower())
if episode <= len(sorted_files):
selected_file = sorted_files[episode - 1] # Index 0-based
logger.debug(f"Smart fallback: Sélection par ordre alphabétique (épisode #{episode}): {selected_file.get('name')}")
logger.info(f"Smart fallback: Sélection par ordre alphabétique (épisode #{episode}): {selected_file.get('name')}")
return selected_file
largest_file = max(video_files, key=lambda f: f.get("size", 0))
logger.warning(f"Smart fallback: Aucune stratégie n'a fonctionné, sélection du plus gros fichier: {largest_file.get('name')}")
return largest_file

View file

@ -1,13 +1,10 @@
import os
import queue
import threading
import time
import asyncio
import aiohttp
import xml.etree.ElementTree as ET
from typing import List, Optional
import requests
from RTN import parse
from requests_ratelimiter import HTTPAdapter
from urllib3 import Retry
from stream_fusion.utils.jackett.jackett_indexer import JackettIndexer
from stream_fusion.utils.jackett.jackett_result import JackettResult
@ -19,77 +16,83 @@ from stream_fusion.settings import settings
class JackettService:
def __init__(self, config):
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
self.logger = logger
self.__api_key = settings.jackett_api_key
self.__base_url = f"{settings.jackett_schema}://{settings.jackett_host}:{settings.jackett_port}/api/v2.0"
self.__session = requests.Session()
adapter = HTTPAdapter(pool_connections=100, pool_maxsize=100)
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS"]
)
adapter.max_retries = retry_strategy
self.__session.mount("http://", adapter)
self.__session.mount("https://", adapter)
def search(self, media):
self._external_session = session is not None
self._session = session
self._timeout = aiohttp.ClientTimeout(total=30)
async def _get_session(self) -> aiohttp.ClientSession:
"""Retourne la session aiohttp, en crée une si nécessaire."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=self._timeout)
self._external_session = False
return self._session
async def close(self):
"""Ferme la session si elle a été créée en interne."""
if self._session and not self._external_session and not self._session.closed:
await self._session.close()
async def search(self, media) -> List[JackettResult]:
self.logger.info("Started Jackett search for " + media.type + " " + media.titles[0])
indexers = self.__get_indexers()
threads = []
results_queue = queue.Queue() # Create a Queue instance to hold the results
indexers = await self.__get_indexers()
# Define a wrapper function that calls the actual target function and stores its return value in the queue
def thread_target(media, indexer):
self.logger.info(f"Searching on {indexer.title}")
start_time = time.time()
# Call the actual function
if isinstance(media, Movie):
result = self.__search_movie_indexer(media, indexer)
elif isinstance(media, Series):
result = self.__search_series_indexer(media, indexer)
else:
raise TypeError("Only Movie and Series is allowed as media!")
self.logger.info(
f"Search on {indexer.title} took {time.time() - start_time} seconds and found {len([result for sublist in result for result in sublist])} results")
results_queue.put(result) # Put the result in the queue
if isinstance(media, Movie):
search_func = self.__search_movie_indexer
elif isinstance(media, Series):
search_func = self.__search_series_indexer
else:
raise TypeError("Only Movie and Series is allowed as media!")
# Lancer toutes les recherches en parallèle avec asyncio.gather()
import time
tasks = []
for indexer in indexers:
# Pass the wrapper function as the target to Thread, with necessary arguments
threads.append(threading.Thread(target=thread_target, args=(media, indexer)))
tasks.append(self.__search_indexer_wrapper(media, indexer, search_func))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
results_nested = await asyncio.gather(*tasks, return_exceptions=True)
# Aplatir les résultats
results = []
for result in results_nested:
if isinstance(result, Exception):
self.logger.exception(f"Error in Jackett search: {result}")
elif result:
for sublist in result:
if sublist:
results.extend(sublist)
# Retrieve results from the queue and append them to the results list
while not results_queue.empty():
results.extend(results_queue.get())
return self.__post_process_results(results, media)
flatten_results = [result for sublist in results for result in sublist]
async def __search_indexer_wrapper(self, media, indexer, search_func):
"""Wrapper pour mesurer le temps de recherche par indexer."""
import time
self.logger.info(f"Searching on {indexer.title}")
start_time = time.time()
return self.__post_process_results(flatten_results, media)
try:
result = await search_func(media, indexer)
count = len([r for sublist in result for r in sublist]) if result else 0
self.logger.info(
f"Search on {indexer.title} took {time.time() - start_time:.2f} seconds and found {count} results"
)
return result
except Exception as e:
self.logger.exception(f"Error searching on {indexer.title}: {e}")
return []
def __search_movie_indexer(self, movie, indexer):
# url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=movie&cat=2000&q={movie.title}&year={movie.year}"
has_imdb_search_capability = (os.getenv(
"DISABLE_JACKETT_IMDB_SEARCH") != "true" and indexer.movie_search_capatabilities is not None and 'imdbid' in indexer.movie_search_capatabilities)
async def __search_movie_indexer(self, movie: Movie, indexer: JackettIndexer) -> List[List[JackettResult]]:
has_imdb_search_capability = (
os.getenv("DISABLE_JACKETT_IMDB_SEARCH") != "true"
and indexer.movie_search_capatabilities is not None
and 'imdbid' in indexer.movie_search_capatabilities
)
if has_imdb_search_capability:
languages = ['en']
@ -99,12 +102,15 @@ class JackettService:
languages = movie.languages
titles = movie.titles
else:
index_of_language = [index for index, lang in enumerate(movie.languages) if
lang == indexer.language or lang == 'en']
index_of_language = [
index for index, lang in enumerate(movie.languages)
if lang == indexer.language or lang == 'en'
]
languages = [movie.languages[index] for index in index_of_language]
titles = [movie.titles[index] for index in index_of_language]
results = []
session = await self._get_session()
for index, lang in enumerate(languages):
params = {
@ -122,23 +128,28 @@ class JackettService:
url += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
try:
response = self.__session.get(url)
response.raise_for_status()
results.append(self.__get_torrent_links_from_xml(response.text))
async with session.get(url) as response:
response.raise_for_status()
text = await response.text()
results.append(self.__get_torrent_links_from_xml(text))
except Exception:
self.logger.exception(
f"An exception occured while searching for a movie on Jackett with indexer {indexer.title} and "
f"language {lang}.")
f"An exception occurred while searching for a movie on Jackett with indexer {indexer.title} and "
f"language {lang}."
)
return results
def __search_series_indexer(self, series, indexer):
async def __search_series_indexer(self, series: Series, indexer: JackettIndexer) -> List[List[JackettResult]]:
season = str(int(series.season.replace('S', '')))
episode = str(int(series.episode.replace('E', '')))
has_imdb_search_capability = (os.getenv("DISABLE_JACKETT_IMDB_SEARCH") != "true"
and indexer.tv_search_capatabilities is not None
and 'imdbid' in indexer.tv_search_capatabilities)
has_imdb_search_capability = (
os.getenv("DISABLE_JACKETT_IMDB_SEARCH") != "true"
and indexer.tv_search_capatabilities is not None
and 'imdbid' in indexer.tv_search_capatabilities
)
if has_imdb_search_capability:
languages = ['en']
index_of_language = [index for index, lang in enumerate(series.languages) if lang == 'en'][0]
@ -147,12 +158,15 @@ class JackettService:
languages = series.languages
titles = series.titles
else:
index_of_language = [index for index, lang in enumerate(series.languages) if
lang == indexer.language or lang == 'en']
index_of_language = [
index for index, lang in enumerate(series.languages)
if lang == indexer.language or lang == 'en'
]
languages = [series.languages[index] for index in index_of_language]
titles = [series.titles[index] for index in index_of_language]
results = []
session = await self._get_session()
for index, lang in enumerate(languages):
params = {
@ -169,24 +183,24 @@ class JackettService:
url_title += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
url_season = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
params['season'] = season
url_season += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
params_season = {**params, 'season': season}
url_season += '?' + '&'.join([f'{k}={v}' for k, v in params_season.items()])
url_ep = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
params['ep'] = episode
url_ep += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
params_ep = {**params_season, 'ep': episode}
url_ep += '?' + '&'.join([f'{k}={v}' for k, v in params_ep.items()])
try:
# Current functionality is that it returns if the season, episode search was successful. This is subject to change
# TODO: what should we prioritize? season, episode or title?
response_ep = self.__session.get(url_ep)
response_ep.raise_for_status()
# Lancer les 3 requêtes en parallèle
async with session.get(url_ep) as response_ep:
response_ep.raise_for_status()
text_ep = await response_ep.text()
data_ep = self.__get_torrent_links_from_xml(text_ep)
response_season = self.__session.get(url_season)
response_season.raise_for_status()
data_ep = self.__get_torrent_links_from_xml(response_ep.text)
data_season = self.__get_torrent_links_from_xml(response_season.text)
async with session.get(url_season) as response_season:
response_season.raise_for_status()
text_season = await response_season.text()
data_season = self.__get_torrent_links_from_xml(text_season)
if data_ep:
results.append(data_ep)
@ -194,29 +208,33 @@ class JackettService:
results.append(data_season)
if not data_ep and not data_season:
response_title = self.__session.get(url_title)
response_title.raise_for_status()
data_title = self.__get_torrent_links_from_xml(response_title.text)
if data_title:
results.append(data_title)
async with session.get(url_title) as response_title:
response_title.raise_for_status()
text_title = await response_title.text()
data_title = self.__get_torrent_links_from_xml(text_title)
if data_title:
results.append(data_title)
except Exception:
self.logger.exception(
f"An exception occured while searching for a series on Jackett with indexer {indexer.title} and language {lang}.")
f"An exception occurred while searching for a series on Jackett with indexer {indexer.title} and language {lang}."
)
return results
def __get_indexers(self):
async def __get_indexers(self) -> List[JackettIndexer]:
url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=indexers&configured=true"
session = await self._get_session()
try:
response = self.__session.get(url)
response.raise_for_status()
return self.__get_indexer_from_xml(response.text)
async with session.get(url) as response:
response.raise_for_status()
text = await response.text()
return self.__get_indexer_from_xml(text)
except Exception:
self.logger.exception("An exception occured while getting indexers from Jackett.")
self.logger.exception("An exception occurred while getting indexers from Jackett.")
return []
def __get_indexer_from_xml(self, xml_content):
def __get_indexer_from_xml(self, xml_content: str) -> List[JackettIndexer]:
xml_root = ET.fromstring(xml_content)
indexer_list = []
@ -248,15 +266,17 @@ class JackettService:
return indexer_list
def __get_torrent_links_from_xml(self, xml_content):
def __get_torrent_links_from_xml(self, xml_content: str) -> List[JackettResult]:
xml_root = ET.fromstring(xml_content)
result_list = []
for item in xml_root.findall('.//item'):
result = JackettResult()
result.seeders = item.find('.//torznab:attr[@name="seeders"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}).attrib['value']
result.seeders = item.find(
'.//torznab:attr[@name="seeders"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}
).attrib['value']
if int(result.seeders) <= 0:
continue
@ -266,24 +286,26 @@ class JackettService:
result.indexer = item.find('jackettindexer').text
result.privacy = item.find('type').text
# TODO: I haven't seen this in the Jackett XML response. Is this still relevant?
# Or which indexers provide this?
magnet = item.find('.//torznab:attr[@name="magneturl"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'})
magnet = item.find(
'.//torznab:attr[@name="magneturl"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}
)
result.magnet = magnet.attrib['value'] if magnet is not None else None
infoHash = item.find('.//torznab:attr[@name="infohash"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'})
infoHash = item.find(
'.//torznab:attr[@name="infohash"]',
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}
)
result.info_hash = infoHash.attrib['value'] if infoHash is not None else None
result_list.append(result)
return result_list
def __post_process_results(self, results, media):
def __post_process_results(self, results: List[JackettResult], media) -> List[JackettResult]:
for result in results:
parsed_result = parse(result.raw_title)
result.parsed_data = parsed_result
result.languages = detect_languages(result.raw_title)
result.type = media.type

View file

@ -1,4 +1,6 @@
import requests
import os
import aiohttp
from typing import Optional
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
from stream_fusion.utils.models.movie import Movie
@ -6,28 +8,81 @@ from stream_fusion.utils.models.series import Series
class Cinemeta(MetadataProvider):
def get_metadata(self, id, type):
# Manual IMDB to TMDB ID mapping for series without proper IMDB linking on TMDB
IMDB_TO_TMDB_MAPPING = {
"tt38776705": "272565", # Culte - 2Be3
}
# Manual series title mapping for fallback (when Cinemeta doesn't return series name)
SERIES_TITLE_MAPPING = {
"tt38776705": "Culte - 2Be3",
}
async def get_metadata(self, id, type):
self.logger.info("Getting metadata for " + type + " with id " + id)
full_id = id.split(":")
imdb_id = full_id[0]
url = f"https://v3-cinemeta.strem.io/meta/{type}/{full_id[0]}.json"
response = requests.get(url)
data = response.json()
session = await self._get_session()
# Requête Cinemeta
url = f"https://v3-cinemeta.strem.io/meta/{type}/{imdb_id}.json"
async with session.get(url) as response:
data = await response.json()
# Handle missing name field - use fallback if needed
title = None
if "name" in data.get("meta", {}):
title = self.replace_weird_characters(data["meta"]["name"])
elif type == "series":
# Check manual series title mapping first
if imdb_id in self.SERIES_TITLE_MAPPING:
title = self.SERIES_TITLE_MAPPING[imdb_id]
self.logger.info(f"Using manual series title mapping: {imdb_id}{title}")
elif "videos" in data.get("meta", {}) and data["meta"]["videos"]:
# Fallback: use first video/episode name as series title
first_video = data["meta"]["videos"][0] if isinstance(data["meta"]["videos"], list) else None
if first_video:
episode_name = first_video.get("name", "Unknown")
title = self.replace_weird_characters(episode_name)
else:
title = "Unknown"
else:
title = "Unknown"
# Check manual mapping for TMDB ID first
tmdb_id = self.IMDB_TO_TMDB_MAPPING.get(imdb_id, None)
# If not in manual mapping, try to find TMDB ID from title using TMDB API
if not tmdb_id:
try:
tmdb_api_key = os.environ.get("TMDB_API_KEY", "")
if title and title != "Unknown" and tmdb_api_key:
tmdb_url = f"https://api.themoviedb.org/3/search/{type}?query={title}&api_key={tmdb_api_key}"
async with session.get(tmdb_url, timeout=aiohttp.ClientTimeout(total=5)) as tmdb_response:
tmdb_data = await tmdb_response.json()
if tmdb_data.get("results"):
tmdb_id = str(tmdb_data["results"][0]["id"])
self.logger.info(f"Found TMDB ID {tmdb_id} for {type} '{title}'")
except Exception as e:
self.logger.warning(f"Failed to find TMDB ID for '{title}': {e}")
else:
self.logger.info(f"Using manual mapping: IMDB {imdb_id} → TMDB {tmdb_id}")
if type == "movie":
result = Movie(
id=id,
tmdb_id=None,
titles=[self.replace_weird_characters(data["meta"]["name"])],
year=data["meta"]["year"],
tmdb_id=tmdb_id,
titles=[title],
year=data["meta"].get("year", 2024),
languages=["en"]
)
else:
result = Series(
id=id,
tmdb_id=None,
titles=[self.replace_weird_characters(data["meta"]["name"])],
tmdb_id=tmdb_id,
titles=[title],
season="S{:02d}".format(int(full_id[1])),
episode="E{:02d}".format(int(full_id[2])),
languages=["en"]

View file

@ -1,11 +1,27 @@
import aiohttp
from typing import Optional
from stream_fusion.logging_config import logger
class MetadataProvider:
def __init__(self, config):
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
self.config = config
self.logger = logger
self._external_session = session is not None
self._session = session
self._timeout = aiohttp.ClientTimeout(total=10)
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=self._timeout)
self._external_session = False
return self._session
async def close(self):
if self._session and not self._external_session and not self._session.closed:
await self._session.close()
def replace_weird_characters(self, string):
corresp = {

View file

@ -1,4 +1,6 @@
import requests
import asyncio
import aiohttp
from typing import Optional
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
from stream_fusion.utils.models.movie import Movie
@ -6,22 +8,30 @@ from stream_fusion.utils.models.series import Series
from stream_fusion.settings import settings
from stream_fusion.logging_config import logger
class TMDB(MetadataProvider):
def get_metadata(self, id, type):
async def get_metadata(self, id, type):
self.logger.info("Getting metadata for " + type + " with id " + id)
full_id = id.split(":")
session = await self._get_session()
result = None
for lang in self.config['languages']:
url = f"https://api.themoviedb.org/3/find/{full_id[0]}?api_key={settings.tmdb_api_key}&external_source=imdb_id&language={lang}"
response = requests.get(url)
data = response.json()
async with session.get(url) as response:
data = await response.json()
logger.trace(data)
if lang == self.config['languages'][0]:
if type == "movie":
# Vérifier si movie_results n'est pas vide
if not data.get("movie_results") or len(data["movie_results"]) == 0:
raise ValueError(f"No TMDB results found for movie with IMDB ID {full_id[0]}")
result = Movie(
id=id,
tmdb_id=data["movie_results"][0]["id"],
@ -30,19 +40,29 @@ class TMDB(MetadataProvider):
languages=self.config['languages']
)
else:
# Vérifier si tv_results n'est pas vide
if not data.get("tv_results") or len(data["tv_results"]) == 0:
raise ValueError(f"No TMDB results found for series with IMDB ID {full_id[0]}")
tmdb_id = data["tv_results"][0]["id"]
season_num = int(full_id[1])
episode_num = int(full_id[2])
result = Series(
id=id,
tmdb_id = data["tv_results"][0]["id"],
tmdb_id=tmdb_id,
titles=[self.replace_weird_characters(data["tv_results"][0]["name"])],
season="S{:02d}".format(int(full_id[1])),
episode="E{:02d}".format(int(full_id[2])),
season="S{:02d}".format(season_num),
episode="E{:02d}".format(episode_num),
languages=self.config['languages']
)
else:
if type == "movie":
result.titles.append(self.replace_weird_characters(data["movie_results"][0]["title"]))
if data.get("movie_results") and len(data["movie_results"]) > 0:
result.titles.append(self.replace_weird_characters(data["movie_results"][0]["title"]))
else:
result.titles.append(self.replace_weird_characters(data["tv_results"][0]["name"]))
if data.get("tv_results") and len(data["tv_results"]) > 0:
result.titles.append(self.replace_weird_characters(data["tv_results"][0]["name"]))
self.logger.info("Got metadata for " + type + " with id " + id)
return result

View file

@ -7,3 +7,15 @@ class Series(Media):
self.season = season
self.episode = episode
self.seasonfile = None
def get_season_number(self) -> int:
"""Extract season number from season string (e.g., 'S02' -> 2)"""
if self.season and self.season.startswith('S'):
return int(self.season[1:])
return 0
def get_episode_number(self) -> int:
"""Extract episode number from episode string (e.g., 'E05' -> 5)"""
if self.episode and self.episode.startswith('E'):
return int(self.episode[1:])
return 0

View file

@ -20,4 +20,16 @@ def parse_config(b64config):
if "addonHost" not in config:
logger.warning("addonHost not found in config, using default")
config["addonHost"] = "http://127.0.0.1:8000"
config["sharewood"] = False
if "c411" not in config:
config["c411"] = True
if "torr9" not in config:
config["torr9"] = True
if "yggflix" not in config:
config["yggflix"] = True
return config

View file

@ -1,6 +1,5 @@
import json
import queue
import threading
import asyncio
from typing import List, Dict
from RTN import ParsedData
@ -26,61 +25,73 @@ class StreamParser:
self.config = config
self.configb64 = encodeb64(json.dumps(config).replace("=", "%3D"))
def parse_to_stremio_streams(
async def parse_to_stremio_streams(
self, torrent_items: List[TorrentItem], media: Media
) -> List[Dict]:
"""Parse async avec asyncio.gather() au lieu de threading"""
# Limite le nombre de résultats
limited_items = torrent_items[: int(self.config["maxResults"])]
# Créer des tâches async pour chaque torrent_item
tasks = [
self._parse_to_debrid_stream_async(torrent_item, media)
for torrent_item in limited_items
]
# Exécuter en parallèle
stream_results = await asyncio.gather(*tasks, return_exceptions=True)
# Filtrer les exceptions et aplatir les résultats
stream_list = []
threads = []
thread_results_queue = queue.Queue()
for torrent_item in torrent_items[: int(self.config["maxResults"])]:
thread = threading.Thread(
target=self._parse_to_debrid_stream,
args=(torrent_item, thread_results_queue, media),
daemon=True,
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
while not thread_results_queue.empty():
stream_list.append(thread_results_queue.get())
for result in stream_results:
if isinstance(result, Exception):
# Log mais continue
continue
if isinstance(result, list):
stream_list.extend(result)
elif result:
stream_list.append(result)
if self.config["debrid"]:
stream_list = sorted(stream_list, key=filter_by_availability)
stream_list = sorted(stream_list, key=filter_by_direct_torrent)
is_torbox = (bool(self.config.get("TBToken")) or self.config.get("debridDownloader") == "TorBox")
def combined_sort_key(item):
name = item["name"]
desc = item.get("description", "")
if name.startswith(DIRECT_TORRENT):
return 0
if INSTANTLY_AVAILABLE in name:
return 1
if is_torbox and ("C411" in desc or "Torr9" in desc):
return 2
if "C411" in desc or "Torr9" in desc:
return 4
return 3
stream_list = sorted(stream_list, key=combined_sort_key)
return stream_list
def _generate_binge_group(self, torrent_item: TorrentItem, media: Media) -> str:
"""Génère un bingeGroup intelligent selon le type de média"""
if media.type == "movie":
return f"stream-fusion-{torrent_item.info_hash}"
if media.type == "series":
# Pour les séries, utiliser l'ID de la série + résolution + team pour permettre
# la lecture automatique même avec des torrents d'épisodes individuels
series_id = media.id.split(":")[0] if ":" in media.id else media.id
resolution = torrent_item.parsed_data.resolution if torrent_item.parsed_data.resolution else "Unknown"
# Ajouter la team si disponible pour une meilleure granularité
team = extract_release_group(torrent_item.raw_title) or torrent_item.parsed_data.group
if team:
return f"stream-fusion-{series_id}-{resolution}-{team}"
else:
return f"stream-fusion-{series_id}-{resolution}"
# Fallback
return f"stream-fusion-{torrent_item.info_hash}"
async def _parse_to_debrid_stream_async(
self, torrent_item: TorrentItem, media: Media
) -> List[Dict]:
"""Version async qui retourne une liste de streams"""
return await asyncio.to_thread(
self._parse_to_debrid_stream_sync, torrent_item, media
)
def _parse_to_debrid_stream_sync(
self, torrent_item: TorrentItem, media: Media
) -> List[Dict]:
"""Version synchrone du parsing (CPU-bound)"""
# Ensure parsed_data is valid ParsedData object, not string or dict
from RTN import parse
# Force reparsing if not ParsedData
if torrent_item.parsed_data is None or not isinstance(torrent_item.parsed_data, ParsedData):
torrent_item.parsed_data = parse(torrent_item.raw_title)
def _parse_to_debrid_stream(
self, torrent_item: TorrentItem, results: queue.Queue, media: Media
) -> None:
parsed_data: ParsedData = torrent_item.parsed_data
name = self._create_stream_name(torrent_item, parsed_data)
title = self._create_stream_title(torrent_item, parsed_data, media)
@ -89,20 +100,49 @@ class StreamParser:
json.dumps(torrent_item.to_debrid_stream_query(media))
).replace("=", "%3D")
results.put(
{
"name": name,
"description": title,
"url": f"{self.config['addonHost']}/playback/{self.configb64}/{queryb64}",
"behaviorHints": {
"bingeGroup": self._generate_binge_group(torrent_item, media),
"filename": torrent_item.file_name or torrent_item.raw_title,
},
}
)
results = []
# Stream principal debrid
results.append({
"name": name,
"description": title,
"url": f"{self.config['addonHost']}/playback/{self.configb64}/{queryb64}",
"infoHash": torrent_item.info_hash,
"behaviorHints": {
"bingeGroup": self._generate_binge_group(torrent_item, media),
"filename": torrent_item.file_name or torrent_item.raw_title,
},
})
# Ajouter le stream direct torrent si applicable
if self.config["torrenting"] and torrent_item.privacy == "public":
self._add_direct_torrent_stream(torrent_item, parsed_data, title, results, media)
direct_stream = self._create_direct_torrent_stream(torrent_item, parsed_data, title, media)
if direct_stream:
results.append(direct_stream)
return results
def _generate_binge_group(self, torrent_item: TorrentItem, media: Media) -> str:
"""Génère un bingeGroup intelligent selon le type de média"""
if media.type == "movie":
return f"stream-fusion-{torrent_item.info_hash}"
if media.type == "series":
# Pour les séries, utiliser l'ID de la série + résolution + team pour permettre
# la lecture automatique même avec des torrents d'épisodes individuels
series_id = media.id.split(":")[0] if ":" in media.id else media.id
resolution = torrent_item.parsed_data.resolution if torrent_item.parsed_data.resolution else "Unknown"
# Ajouter la team si disponible pour une meilleure granularité
team = extract_release_group(torrent_item.raw_title) or torrent_item.parsed_data.group
if team:
return f"stream-fusion-{series_id}-{resolution}-{team}"
else:
return f"stream-fusion-{series_id}-{resolution}"
# Fallback
return f"stream-fusion-{torrent_item.info_hash}"
def _create_stream_name(
self, torrent_item: TorrentItem, parsed_data: ParsedData
@ -133,10 +173,8 @@ class StreamParser:
def _create_stream_title(
self, torrent_item: TorrentItem, parsed_data: ParsedData, media: Media
) -> str:
title = f"{torrent_item.raw_title}\n"
title = f"{torrent_item.file_name}\n" if torrent_item.file_name else f"{torrent_item.raw_title}\n"
if media.type == "series" and torrent_item.file_name:
title += f"{torrent_item.file_name}\n"
title += self._add_language_info(torrent_item, parsed_data)
title += self._add_torrent_info(torrent_item)
@ -173,33 +211,38 @@ class StreamParser:
info.append(f"🎥 {parsed_data.codec}")
if parsed_data.quality:
info.append(f"📺 {parsed_data.quality}")
# Ajouter les informations HDR/Dolby Vision/SDR
if parsed_data.hdr:
hdr_info = ' '.join(parsed_data.hdr)
info.append(f"🌈 {hdr_info}")
elif parsed_data.resolution == "2160p":
# Si c'est du 4K sans HDR, c'est du SDR
info.append("🌈 SDR")
if parsed_data.audio:
info.append(f"🎧 {' '.join(parsed_data.audio)}")
return " ".join(info) + "\n" if info else ""
def _add_direct_torrent_stream(
def _create_direct_torrent_stream(
self,
torrent_item: TorrentItem,
parsed_data: ParsedData,
title: str,
results: queue.Queue,
media: Media,
) -> None:
) -> Dict:
"""Crée un stream direct torrent"""
direct_torrent_name = f"{DIRECT_TORRENT}\n{parsed_data.quality}\n"
if parsed_data.quality and parsed_data.quality[0] not in ["Unknown", ""]:
direct_torrent_name += f"({'|'.join(parsed_data.quality)})"
results.put(
{
"name": direct_torrent_name,
"description": title,
"infoHash": torrent_item.info_hash,
"fileIdx": (
int(torrent_item.file_index) if torrent_item.file_index else None
),
"behaviorHints": {
"bingeGroup": self._generate_binge_group(torrent_item, media),
"filename": torrent_item.file_name or torrent_item.raw_title,
},
}
)
return {
"name": direct_torrent_name,
"description": title,
"infoHash": torrent_item.info_hash,
"fileIdx": (
int(torrent_item.file_index) if torrent_item.file_index else None
),
"behaviorHints": {
"bingeGroup": self._generate_binge_group(torrent_item, media),
"filename": torrent_item.file_name or torrent_item.raw_title,
},
}

View file

@ -1,74 +1,75 @@
import time
from urllib3.util.retry import Retry
import requests
from requests.adapters import HTTPAdapter
import asyncio
import aiohttp
from typing import Optional
from stream_fusion.settings import settings
from stream_fusion.logging_config import logger
class RateLimiter:
def __init__(self, calls_per_second=1):
self.calls_per_second = calls_per_second
self.last_call = 0
class AsyncRateLimiter:
"""Rate limiter async pour contrôler le débit des requêtes."""
def __call__(self, func):
def wrapper(*args, **kwargs):
def __init__(self, calls_per_second: float = 1.0):
self.min_interval = 1.0 / calls_per_second
self.last_call = 0.0
self._lock = asyncio.Lock()
async def acquire(self):
"""Attend le temps nécessaire avant d'autoriser une nouvelle requête."""
async with self._lock:
import time
now = time.time()
time_since_last_call = now - self.last_call
if time_since_last_call < 1 / self.calls_per_second:
time.sleep(1 / self.calls_per_second - time_since_last_call)
if time_since_last_call < self.min_interval:
await asyncio.sleep(self.min_interval - time_since_last_call)
self.last_call = time.time()
return func(*args, **kwargs)
return wrapper
class SharewoodAPI:
def __init__(
self,
sharewood_passkey: str,
pool_connections=10,
pool_maxsize=10,
max_retries=3,
timeout=10,
session: Optional[aiohttp.ClientSession] = None,
timeout: int = 10,
):
self.base_url = f"{settings.sharewood_url}/api"
if not sharewood_passkey or len(sharewood_passkey) != 32:
raise ValueError("Sharewood passkey must be 32 characters long")
self.sharewood_passkey = sharewood_passkey
self.timeout = timeout
self.session = requests.Session()
self.rate_limiter = RateLimiter(calls_per_second=1)
self.timeout = aiohttp.ClientTimeout(total=timeout)
self.rate_limiter = AsyncRateLimiter(calls_per_second=1)
retry_strategy = Retry(
total=max_retries,
backoff_factor=0.1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS"],
)
adapter = HTTPAdapter(
pool_connections=pool_connections,
pool_maxsize=pool_maxsize,
max_retries=retry_strategy,
)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
self._external_session = session is not None
self._session = session
async def _get_session(self) -> aiohttp.ClientSession:
"""Retourne la session aiohttp, en crée une si nécessaire."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=self.timeout)
self._external_session = False
return self._session
async def close(self):
"""Ferme la session si elle a été créée en interne."""
if self._session and not self._external_session and not self._session.closed:
await self._session.close()
async def _make_request(self, method: str, endpoint: str, params: dict = None):
"""Effectue une requête HTTP async avec rate limiting."""
await self.rate_limiter.acquire()
@RateLimiter()
def _make_request(self, method, endpoint, params=None):
url = f"{self.base_url}/{self.sharewood_passkey}/{endpoint}"
session = await self._get_session()
try:
response = self.session.request(
method, url, params=params, timeout=self.timeout
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
async with session.request(method, url, params=params) as response:
response.raise_for_status()
return await response.json()
except aiohttp.ClientError as e:
logger.error(f"An error occurred during the request: {e}")
raise
def get_last_torrents(self, category=None, subcategory=None, limit=25):
async def get_last_torrents(self, category: int = None, subcategory: int = None, limit: int = 25):
"""Get the last torrents, optionally filtered by category or subcategory."""
params = {}
if category:
@ -77,57 +78,56 @@ class SharewoodAPI:
params["subcategory"] = subcategory
if limit and 1 <= limit <= 25:
params["limit"] = limit
return self._make_request("GET", "last-torrents", params=params)
return await self._make_request("GET", "last-torrents", params=params)
def search(self, query, category=None, subcategory=None):
async def search(self, query: str, category: int = None, subcategory: int = None):
"""Search for torrents, optionally filtered by category or subcategory."""
params = {"name": query}
if category:
params["category"] = category
if subcategory:
params["subcategory"] = subcategory
return self._make_request("GET", "search", params=params)
return await self._make_request("GET", "search", params=params)
def get_video_torrents(self, limit=25):
async def get_video_torrents(self, limit: int = 25):
"""Get the last video torrents."""
return self.get_last_torrents(category=1, limit=limit)
return await self.get_last_torrents(category=1, limit=limit)
def get_audio_torrents(self, limit=25):
async def get_audio_torrents(self, limit: int = 25):
"""Get the last audio torrents."""
return self.get_last_torrents(category=2, limit=limit)
return await self.get_last_torrents(category=2, limit=limit)
def get_application_torrents(self, limit=25):
async def get_application_torrents(self, limit: int = 25):
"""Get the last application torrents."""
return self.get_last_torrents(category=3, limit=limit)
return await self.get_last_torrents(category=3, limit=limit)
def get_ebook_torrents(self, limit=25):
async def get_ebook_torrents(self, limit: int = 25):
"""Get the last ebook torrents."""
return self.get_last_torrents(category=4, limit=limit)
return await self.get_last_torrents(category=4, limit=limit)
def get_game_torrents(self, limit=25):
async def get_game_torrents(self, limit: int = 25):
"""Get the last game torrents."""
return self.get_last_torrents(category=5, limit=limit)
return await self.get_last_torrents(category=5, limit=limit)
def get_training_torrents(self, limit=25):
async def get_training_torrents(self, limit: int = 25):
"""Get the last training torrents."""
return self.get_last_torrents(category=6, limit=limit)
return await self.get_last_torrents(category=6, limit=limit)
def get_adult_torrents(self, limit=25):
async def get_adult_torrents(self, limit: int = 25):
"""Get the last adult torrents."""
return self.get_last_torrents(category=7, limit=limit)
return await self.get_last_torrents(category=7, limit=limit)
@RateLimiter()
def download_torrent(self, torrent_id):
async def download_torrent(self, torrent_id: int) -> bytes:
"""Download a specific torrent file."""
await self.rate_limiter.acquire()
url = f"{self.base_url}/{self.sharewood_passkey}/{torrent_id}/download"
session = await self._get_session()
try:
response = self.session.get(url, timeout=self.timeout)
response.raise_for_status()
return response.content
except requests.exceptions.RequestException as e:
async with session.get(url) as response:
response.raise_for_status()
return await response.read()
except aiohttp.ClientError as e:
logger.error(f"An error occurred while downloading the torrent: {e}")
raise
def __del__(self):
"""Close the session when the object is destroyed."""
self.session.close()

View file

@ -1,6 +1,7 @@
import re
import urllib.parse
from typing import List, Union
import aiohttp
from typing import List, Union, Optional
from RTN import parse
from stream_fusion.logging_config import logger
@ -13,17 +14,17 @@ from stream_fusion.utils.sharewood.sharewood_api import SharewoodAPI
class SharewoodService:
"""Service for searching media on Sharewood."""
"""Service for searching media on Sharewood (async version)."""
def __init__(self, config: dict):
def __init__(self, config: dict, session: Optional[aiohttp.ClientSession] = None):
self.sharewood_url = settings.sharewood_url
if settings.sharewood_unique_account and settings.sharewood_passkey:
self.sharewood_passkey = settings.sharewood_passkey
else:
self.sharewood_passkey = config.get("sharewoodPasskey")
self.sharewood = SharewoodAPI(self.sharewood_passkey)
self.sharewood = SharewoodAPI(self.sharewood_passkey, session=session)
def search(self, media: Union[Movie, Series]) -> List[SharewoodResult]:
async def search(self, media: Union[Movie, Series]) -> List[SharewoodResult]:
"""
Search for a media (movie or series) on sharewood.
@ -31,15 +32,15 @@ class SharewoodService:
media (Union[Movie, Series]): The media to search for.
Returns:
List[sharewoodResult]: List of search results.
List[SharewoodResult]: List of search results.
Raises:
TypeError: If the media type is neither Movie nor Series.
"""
if isinstance(media, Movie):
results = self.__search_movie(media)
results = await self.__search_movie(media)
elif isinstance(media, Series):
results = self.__search_series(media)
results = await self.__search_series(media)
else:
raise TypeError("Only Movie and Series types are allowed as media!")
@ -59,10 +60,10 @@ class SharewoodService:
"tib": 1024**4,
"pib": 1024**5,
}
if isinstance(size, int):
return size # Si c'est déjà un entier, on le retourne tel quel
if isinstance(size, str):
size = size.lower().replace(',', '.') # Remplace la virgule par un point pour les nombres décimaux
parts = size.split()
@ -75,7 +76,7 @@ class SharewoodService:
if unit not in units:
raise ValueError(f"None support unit : {unit}")
return int(value * units[unit])
raise ValueError(f"unkound size format : {size}")
def __clean_title(self, title):
@ -156,15 +157,17 @@ class SharewoodService:
if not (title.lower() in seen or seen.add(title.lower()))
]
def __search_movie(self, movie: Movie) -> List[dict]:
async def __search_movie(self, movie: Movie) -> List[dict]:
unique_titles = self.__remove_duplicate_titles(movie.titles)
clean_titles = [self.__clean_title(title) for title in unique_titles]
results = []
for title in clean_titles:
results.extend(self.sharewood.search(query=title, category=1))
search_results = await self.sharewood.search(query=title, category=1)
if search_results:
results.extend(search_results)
return self.__deduplicate_api_results(results)
def __search_series(self, series: Series) -> List[dict]:
async def __search_series(self, series: Series) -> List[dict]:
unique_titles = self.__remove_duplicate_titles(series.titles)
clean_titles = [self.__clean_title(title) for title in unique_titles]
search_texts = clean_titles.copy()
@ -176,7 +179,9 @@ class SharewoodService:
results = []
for text in search_texts:
results.extend(self.sharewood.search(query=text, category=1))
search_results = await self.sharewood.search(query=text, category=1)
if search_results:
results.extend(search_results)
return self.__deduplicate_api_results(results)
def __filter_out_no_seeders(self, results: List[dict]) -> List[dict]:
@ -197,7 +202,7 @@ class SharewoodService:
def __post_process_results(
self, results: List[dict], media: Union[Movie, Series]
) -> List[SharewoodResult]:
"""Process raw search results and convert them to sharewoodResult objects."""
"""Process raw search results and convert them to SharewoodResult objects."""
if not results:
logger.info(f"No results found on sharewood for: {media.titles[0]}")
return []
@ -229,3 +234,7 @@ class SharewoodService:
items.append(item)
return items
async def close(self):
"""Ferme les ressources."""
await self.sharewood.close()

View file

View file

@ -0,0 +1,152 @@
import re
import aiohttp
import xml.etree.ElementTree as ET
from typing import List, Optional
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class Torr9RawResult:
def __init__(self):
self.raw_title: Optional[str] = None
self.size: Optional[str] = None
self.link: Optional[str] = None
self.indexer: str = "Torr9 - API"
self.seeders: int = 0
self.magnet: Optional[str] = None
self.info_hash: Optional[str] = None
self.privacy: str = "public"
class Torr9API:
TORZNAB_NS = {"torznab": "http://torznab.com/schemas/2015/feed"}
def __init__(self, session: Optional[aiohttp.ClientSession] = None, api_key: Optional[str] = None):
self.base_url = settings.torr9_url.rstrip("/") + "/api/v1/torznab"
self.api_key = api_key if api_key is not None else settings.torr9_api_key
self._external_session = session is not None
self._session = session
self._timeout = aiohttp.ClientTimeout(sock_read=2)
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=self._timeout)
self._external_session = False
return self._session
async def _request_xml(self, params: dict) -> Optional[str]:
if not self.api_key:
logger.warning("Torr9: API key not configured (TORR9_API_KEY), skipping request")
return None
params["apikey"] = self.api_key
session = await self._get_session()
try:
async with session.get(
self.base_url, params=params, allow_redirects=True,
timeout=aiohttp.ClientTimeout(sock_read=2, total=5)
) as response:
response.raise_for_status()
return await response.text()
except aiohttp.ClientError as e:
logger.error(f"Torr9: HTTP error: {e}")
return None
except Exception as e:
logger.error(f"Torr9: Unexpected error: {e}")
return None
def _parse_xml(self, xml_content: str) -> List[Torr9RawResult]:
results = []
try:
root = ET.fromstring(xml_content)
except ET.ParseError as e:
logger.error(f"Torr9: XML parse error: {e}")
return results
for item in root.findall(".//item"):
try:
result = Torr9RawResult()
seeders_el = item.find(
'.//torznab:attr[@name="seeders"]', self.TORZNAB_NS
)
result.seeders = int(seeders_el.attrib["value"]) if seeders_el is not None else 0
title_el = item.find("title")
if title_el is None or not title_el.text:
continue
result.raw_title = title_el.text
size_el = item.find("size")
result.size = size_el.text if size_el is not None else "0"
link_el = item.find("link")
result.link = link_el.text if link_el is not None else None
magnet_el = item.find(
'.//torznab:attr[@name="magneturl"]', self.TORZNAB_NS
)
result.magnet = magnet_el.attrib["value"] if magnet_el is not None else None
hash_el = item.find(
'.//torznab:attr[@name="infohash"]', self.TORZNAB_NS
)
result.info_hash = hash_el.attrib["value"].lower() if hash_el is not None else None
type_el = item.find("type")
result.privacy = type_el.text if type_el is not None else "public"
# Extract hash from magnet if missing
if not result.info_hash and result.magnet:
m = re.search(r"btih:([a-fA-F0-9]{40})", result.magnet, re.IGNORECASE)
if m:
result.info_hash = m.group(1).lower()
if result.info_hash and len(result.info_hash) == 40:
results.append(result)
except Exception as e:
logger.debug(f"Torr9: Error parsing item: {e}")
continue
return results
async def search_movie(
self,
imdb_id: Optional[str] = None,
title: Optional[str] = None,
) -> List[Torr9RawResult]:
params = {"t": "movie", "cat": "2000"}
if imdb_id:
params["imdbid"] = imdb_id
elif title:
params["q"] = title
else:
return []
xml = await self._request_xml(params)
results = self._parse_xml(xml) if xml else []
logger.info(f"Torr9: search_movie imdb={imdb_id} -> {len(results)} results")
return results
async def search_series(
self,
title: Optional[str] = None,
imdb_id: Optional[str] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> List[Torr9RawResult]:
params = {"t": "tvsearch", "cat": "5000"}
if imdb_id:
params["imdbid"] = imdb_id
elif title:
params["q"] = title
else:
return []
if season is not None:
params["season"] = season
if episode is not None:
params["ep"] = episode
xml = await self._request_xml(params)
results = self._parse_xml(xml) if xml else []
logger.info(f"Torr9: search_series imdb={imdb_id} q={title} s={season} e={episode} -> {len(results)} results")
return results

View file

@ -0,0 +1,62 @@
from RTN import parse
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.utils.detection import detect_languages
from urllib.parse import quote
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class Torr9Result:
def __init__(self):
self.raw_title = None
self.size = None
self.link = None
self.indexer = "Torr9 - API"
self.seeders = 0
self.magnet = None
self.info_hash = None
self.privacy = "public"
self.languages = None
self.type = None
self.parsed_data = None
self.torrent_download = None
def convert_to_torrent_item(self):
parsed_data = self.parsed_data or parse(self.raw_title)
return TorrentItem(
raw_title=self.raw_title,
size=self.size,
magnet=self.magnet,
info_hash=self.info_hash.lower() if self.info_hash else None,
link=self.link or self.magnet,
seeders=self.seeders,
languages=self.languages,
indexer=self.indexer,
privacy=self.privacy,
type=self.type,
parsed_data=parsed_data,
torrent_download=self.torrent_download,
tmdb_id=self.tmdb_id,
)
def from_api_item(self, api_item, media):
self.info_hash = api_item.info_hash.lower() if api_item.info_hash else None
if not self.info_hash or len(self.info_hash) != 40:
raise ValueError(f"Invalid info_hash: {self.info_hash}")
parsed = parse(api_item.raw_title)
self.raw_title = parsed.raw_title
self.parsed_data = parsed
self.size = api_item.size or "0"
torr9_tracker = f"https://tracker.torr9.xyz/announce/{settings.torr9_api_key}"
self.magnet = f"magnet:?xt=urn:btih:{self.info_hash}&dn={self.raw_title}&tr={quote(torr9_tracker, safe='')}"
self.link = self.magnet
self.seeders = api_item.seeders or 0
self.privacy = api_item.privacy or "public"
self.languages = detect_languages(self.raw_title, default_language="fr")
self.type = media.type
self.tmdb_id = getattr(media, 'tmdb_id', None)
self.torrent_download = getattr(api_item, 'torrent_download', None) or api_item.link
return self

View file

@ -0,0 +1,65 @@
from typing import List, Optional, Union
import aiohttp
from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
from stream_fusion.utils.torr9.torr9_api import Torr9API
from stream_fusion.utils.torr9.torr9_result import Torr9Result
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
class Torr9Service:
def __init__(self, config: dict, session: Optional[aiohttp.ClientSession] = None):
self.config = config
if settings.torr9_unique_account and settings.torr9_api_key:
api_key = settings.torr9_api_key
else:
api_key = config.get("torr9ApiKey") or settings.torr9_api_key
self.api = Torr9API(session=session, api_key=api_key)
async def search(self, media: Union[Movie, Series]) -> List[Torr9Result]:
try:
if isinstance(media, Movie):
return await self._search_movie(media)
elif isinstance(media, Series):
return await self._search_series(media)
else:
raise TypeError("Only Movie and Series are supported")
except Exception as e:
logger.error(f"Torr9: Search error: {e}")
return []
async def _search_movie(self, media: Movie) -> List[Torr9Result]:
logger.info(f"Torr9: Searching movie: {media.titles[0]}")
imdb_id = media.id if media.id and media.id.startswith("tt") else None
if not imdb_id:
logger.debug(f"Torr9: No IMDB ID available, skipping search for '{media.titles[0]}'")
return []
raw = await self.api.search_movie(imdb_id=imdb_id)
logger.info(f"Torr9: {len(raw)} raw results for movie '{media.titles[0]}'")
return self._build_results(raw, media)
async def _search_series(self, media: Series) -> List[Torr9Result]:
logger.info(f"Torr9: Searching series (global): {media.titles[0]}")
raw_id = media.id.split(":")[0] if media.id else None
imdb_id = raw_id if raw_id and raw_id.startswith("tt") else None
if not imdb_id:
logger.debug(f"Torr9: No IMDB ID available, skipping search for '{media.titles[0]}'")
return []
# Recherche globale (sans saison/épisode) pour tout stocker en Postgres
raw = await self.api.search_series(imdb_id=imdb_id)
logger.info(f"Torr9: {len(raw)} raw results for '{media.titles[0]}' (global)")
return self._build_results(raw, media)
def _build_results(self, raw_results, media) -> List[Torr9Result]:
results = []
for item in raw_results:
try:
result = Torr9Result().from_api_item(item, media)
results.append(result)
except ValueError as e:
logger.debug(f"Torr9: Skipping item - {e}")
return results

View file

@ -9,7 +9,7 @@ from stream_fusion.logging_config import logger
class TorrentItem:
def __init__(self, raw_title, size, magnet, info_hash, link, seeders, languages, indexer,
privacy, type=None, parsed_data=None):
privacy, type=None, parsed_data=None, torrent_download=None, tmdb_id=None):
self.logger = logger
self.raw_title = raw_title # Raw title of the torrent
@ -22,15 +22,20 @@ class TorrentItem:
self.indexer = indexer # Indexer of the torrent
self.type = type # "series" or "movie"
self.privacy = privacy # "public" or "private"
self.tmdb_id = tmdb_id # TMDB ID for linking with metadata
self.file_name = None # it may be updated during __process_torrent()
self.files = None # The files inside of the torrent. If it's None, it means that there is only one file inside of the torrent
self.torrent_download = None # The torrent jackett download url if its None, it means that there is only a magnet link provided by Jackett. It also means, that we cant do series file filtering before debrid.
self.torrent_download = torrent_download # The torrent jackett download url if its None, it means that there is only a magnet link provided by Jackett. It also means, that we cant do series file filtering before debrid.
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
# Ensure parsed_data is always set (parse if not provided)
if parsed_data is None:
from RTN import parse
parsed_data = parse(raw_title)
self.parsed_data: ParsedData = parsed_data # Ranked result
def to_debrid_stream_query(self, media: Media) -> dict:
@ -40,12 +45,23 @@ class TorrentItem:
"file_index": self.file_index,
"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,
"torrent_download": quote(self.torrent_download) if (self.torrent_download is not None and not self.availability) else None,
"service": self.availability if self.availability else "DL",
"privacy": self.privacy if self.privacy else "private",
}
def to_dict(self):
resolution = getattr(self.parsed_data, 'resolution', 'UNKNOWN') if self.parsed_data else 'NONE'
logger.debug(f"TorrentItem.to_dict(): '{self.raw_title[:60]}' → resolution='{resolution}' (caching)")
# Convert parsed_data to dict if it exists
parsed_data_dict = None
if self.parsed_data:
if isinstance(self.parsed_data, ParsedData):
parsed_data_dict = self.parsed_data.model_dump()
else:
parsed_data_dict = self.parsed_data
return {
'raw_title': self.raw_title,
'size': self.size,
@ -57,6 +73,7 @@ class TorrentItem:
'indexer': self.indexer,
'type': self.type,
'privacy': self.privacy,
'tmdb_id': self.tmdb_id,
'file_name': self.file_name,
'files': self.files,
'torrent_download': self.torrent_download,
@ -64,6 +81,7 @@ class TorrentItem:
'file_index': self.file_index,
'full_index': self.full_index,
'availability': self.availability,
'parsed_data': parsed_data_dict,
}
@classmethod
@ -82,9 +100,10 @@ class TorrentItem:
languages=data['languages'],
indexer=data['indexer'],
privacy=data['privacy'],
type=data['type']
type=data['type'],
tmdb_id=data.get('tmdb_id')
)
instance.file_name = data['file_name']
instance.files = data['files']
instance.torrent_download = data['torrent_download']
@ -92,7 +111,28 @@ class TorrentItem:
instance.file_index = data['file_index']
instance.full_index = data['full_index']
instance.availability = data['availability']
instance.parsed_data = parse(instance.raw_title)
# Use cached parsed_data if available, otherwise parse raw_title
if data.get('parsed_data'):
try:
# Try to reconstruct ParsedData from dict
reconstructed = ParsedData(**data['parsed_data'])
logger.debug(f"TorrentItem.from_dict(): Reconstructed ParsedData: {reconstructed}, resolution={getattr(reconstructed, 'resolution', 'UNKNOWN')}")
# Validate that reconstruction was successful (not an empty/default object)
if reconstructed is not None:
instance.parsed_data = reconstructed
else:
logger.warning(f"TorrentItem.from_dict(): Reconstructed ParsedData is None, will re-parse")
instance.parsed_data = parse(instance.raw_title)
except Exception as e:
logger.warning(f"Failed to reconstruct ParsedData from cache: {e}, will re-parse")
instance.parsed_data = parse(instance.raw_title)
else:
# Fallback to parsing if no cached parsed_data
logger.debug(f"TorrentItem.from_dict(): No parsed_data in cache dict, will parse")
instance.parsed_data = parse(instance.raw_title)
resolution = getattr(instance.parsed_data, 'resolution', 'UNKNOWN') if instance.parsed_data else 'NONE'
logger.debug(f"TorrentItem.from_dict(): '{instance.raw_title[:60]}' → resolution='{resolution}'")
return instance

View file

@ -3,10 +3,13 @@ import os
import time
import urllib.parse
from typing import List
import pathlib
import json
import bencode
import bencodepy
import requests
from RTN import parse
from RTN.models import ParsedData
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
from stream_fusion.utils.jackett.jackett_result import JackettResult
@ -19,11 +22,15 @@ from stream_fusion.logging_config import logger
from stream_fusion.settings import settings
class TorrentService:
TORRENT_CACHE_DIR = pathlib.Path("/var/cache/torrents")
def __init__(self, config, torrent_dao: TorrentItemDAO):
self.config = config
self.torrent_dao = torrent_dao
self.logger = logger
self.__session = requests.Session()
# Ensure cache directory exists
self.TORRENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@staticmethod
def __generate_unique_id(raw_title: str, indexer: str = "cached") -> str:
@ -36,6 +43,7 @@ class TorrentService:
try:
cached_item = await self.torrent_dao.get_torrent_item_by_id(unique_id)
if cached_item:
# to_torrent_item() automatically reprout parsed_data from raw_title
return cached_item.to_torrent_item()
return None
except Exception as e:
@ -44,9 +52,57 @@ class TorrentService:
async def cache_torrent(self, torrent_item: TorrentItem, id: str = None):
unique_id = self.__generate_unique_id(torrent_item.raw_title, torrent_item.indexer)
await self.torrent_dao.create_torrent_item(torrent_item, unique_id)
# C411/Torr9 sans tmdb_id → orphelins, jamais retrouvés → on skip
if torrent_item.indexer in ['C411 - API', 'Torr9 - API'] and not torrent_item.tmdb_id:
self.logger.debug(f"TorrentService: Skipping {torrent_item.indexer} torrent without tmdb_id: {torrent_item.raw_title}")
return
try:
existing = await self.torrent_dao.get_torrent_item_by_id(unique_id)
if existing:
# Already in DB, skip — don't overwrite tmdb_id or other fields
self.logger.debug(f"TorrentService: Torrent already cached, skipping: {unique_id}")
else:
await self.torrent_dao.create_torrent_item(torrent_item, unique_id)
self.logger.debug(f"TorrentService: Created new cached torrent: {unique_id}")
except Exception as e:
if "duplicate key value violates unique constraint" in str(e):
self.logger.debug(f"TorrentService: Race condition, torrent already exists: {unique_id}")
else:
self.logger.error(f"TorrentService: Error caching torrent {unique_id}: {str(e)}")
async def convert_and_process(self, results: List[JackettResult | ZileanResult | YggflixResult | SharewoodResult]):
async def _update_cached_item(self, cached_item: TorrentItem, new_item: TorrentItem):
"""Update cached item with new data (tmdb_id, torrent_file_path) if available"""
try:
unique_id = self.__generate_unique_id(cached_item.raw_title, cached_item.indexer)
needs_update = False
# Update tmdb_id if the new item has one and cached doesn't
if new_item.tmdb_id and not cached_item.tmdb_id:
cached_item.tmdb_id = new_item.tmdb_id
needs_update = True
self.logger.debug(f"Updated tmdb_id for {unique_id}: {new_item.tmdb_id}")
# Update torrent_file_path if new item has one and cached doesn't (or is different)
if new_item.torrent_file_path and not cached_item.torrent_file_path:
cached_item.torrent_file_path = new_item.torrent_file_path
needs_update = True
self.logger.debug(f"Updated torrent_file_path for {unique_id}: {new_item.torrent_file_path}")
if needs_update:
await self.torrent_dao.update_torrent_item(unique_id, cached_item)
self.logger.info(f"Cached torrent updated: {unique_id}")
except Exception as e:
self.logger.error(f"Error updating cached item: {e}")
async def convert_and_process(self, results: List[JackettResult | ZileanResult | YggflixResult | SharewoodResult], skip_yggflix_download: bool = False):
"""
Convert and process torrent results.
Args:
results: List of torrent results to process
skip_yggflix_download: If True, don't download .torrent files from Yggflix (just update metadata)
Used during search to avoid heavy downloads. Set to False for actual playback.
"""
torrent_items_result = []
for result in results:
@ -54,9 +110,25 @@ class TorrentService:
cached_item = await self.get_cached_torrent(torrent_item.raw_title, torrent_item.indexer)
if cached_item:
# Pour Yggflix: mettre à jour les seeders frais en mémoire (sans écriture DB)
if torrent_item.indexer == "Yggtorrent - API":
cached_item.seeders = torrent_item.seeders
self.logger.debug(f"Updated seeders in memory for {torrent_item.raw_title}: {torrent_item.seeders}")
# Don't update - causes DB contention with concurrent requests
# tmdb_id will only be set for NEW torrents, not existing ones
# await self._update_cached_item(cached_item, torrent_item)
torrent_items_result.append(cached_item)
continue
# If skip_yggflix_download is True and this is a Yggflix URL, just cache without downloading
if skip_yggflix_download and settings.yggflix_url and torrent_item.link.startswith(settings.yggflix_url):
# Don't process, just cache the raw item (without .torrent file and info_hash)
# The info_hash will be set to None, magnet will be empty
await self.cache_torrent(torrent_item)
torrent_items_result.append(torrent_item)
continue
if torrent_item.link.startswith("magnet:"):
processed_torrent_item = self.__process_magnet(torrent_item)
elif settings.sharewood_url and torrent_item.link.startswith(settings.sharewood_url):
@ -138,11 +210,11 @@ class TorrentService:
def __process_torrent(self, result: TorrentItem, torrent_file):
try:
metadata = bencode.bdecode(torrent_file)
metadata = bencodepy.decode(torrent_file)
except Exception as e:
try:
from bencodepy import Decoder
decoder = Decoder(encoding='latin-1')
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)}")
@ -153,6 +225,19 @@ class TorrentService:
return result
result.torrent_download = result.link
# Save .torrent file to disk instead of PostgreSQL
try:
# Use info_hash as filename if available, otherwise generate one
filename = f"{result.info_hash if result.info_hash else hashlib.sha256(torrent_file).hexdigest()}.torrent"
filepath = self.TORRENT_CACHE_DIR / filename
with open(filepath, 'wb') as f:
f.write(torrent_file)
result.torrent_file_path = str(filepath)
self.logger.debug(f"Saved .torrent to disk: {filepath}")
except Exception as e:
self.logger.error(f"Error saving .torrent to disk: {e}")
result.torrent_file_path = None
try:
result.trackers = self.__get_trackers_from_torrent(metadata)
result.info_hash = self.__convert_torrent_to_hash(metadata["info"])
@ -170,7 +255,16 @@ class TorrentService:
result.files = metadata["info"]["files"]
if result.type == "series":
file_details = self.__find_single_episode_file(result.files, result.parsed_data.seasons, result.parsed_data.episodes)
# Ensure we have parsed_data from raw_title
if not result.parsed_data:
result.parsed_data = parse(result.raw_title)
# Only try to find episode file if we have valid parsed_data
if result.parsed_data and isinstance(result.parsed_data, ParsedData):
file_details = self.__find_single_episode_file(result.files, result.parsed_data.seasons, result.parsed_data.episodes)
else:
file_details = None
self.logger.warning(f"No valid parsed_data for series torrent: {result.raw_title}")
if file_details is not None:
self.logger.debug("File details")
@ -178,8 +272,9 @@ class TorrentService:
result.file_index = file_details["file_index"]
result.file_name = file_details["title"]
result.size = file_details["size"]
else:
result.full_index = self.__find_full_index(result.files)
# Always create full_index for series - AllDebrid needs all files to search for matching episode
result.full_index = self.__find_full_index(result.files)
if result.type == "movie":
result.file_index = self.__find_movie_file(result.files)
@ -198,7 +293,7 @@ class TorrentService:
return result
def __convert_torrent_to_hash(self, torrent_contents):
hashcontents = bencode.bencode(torrent_contents)
hashcontents = bencodepy.encode(torrent_contents)
hexHash = hashlib.sha1(hashcontents).hexdigest()
return hexHash.lower()
@ -258,7 +353,7 @@ class TorrentService:
if season[0] in parsed_file.seasons and episode[0] in parsed_file.episodes:
episode_files.append({
"file_index": file_index,
"file_index": None, # Let debrid service handle file selection
"title": file,
"size": files["length"]
})
@ -266,7 +361,9 @@ class TorrentService:
# Doesn't that need to be indented?
file_index += 1
return max(episode_files, key=lambda file: file["size"])
if episode_files:
return max(episode_files, key=lambda file: file["size"])
return None
def __find_full_index(self, file_structure):
self.logger.debug("Starting to build full index of video files")

View file

@ -75,24 +75,37 @@ class TorrentSmartContainer:
self.logger.trace(
"TorrentSmartContainer: Item added to best matching (has file index)"
)
else:
matching_file = self._find_matching_file(
torrent_item.full_index,
self.__media.season,
self.__media.episode,
)
if matching_file:
torrent_item.file_index = matching_file["file_index"]
torrent_item.file_name = matching_file["file_name"]
torrent_item.size = matching_file["size"]
elif self.__media.type == "series":
if torrent_item.full_index:
matching_file = self._find_matching_file(
torrent_item.full_index,
self.__media.season,
self.__media.episode,
)
if matching_file:
torrent_item.file_index = matching_file["file_index"]
torrent_item.file_name = matching_file["file_name"]
torrent_item.size = matching_file["size"]
best_matching.append(torrent_item)
self.logger.trace(
f"TorrentSmartContainer: Item added to best matching (found matching file: {matching_file['file_name']})"
)
else:
self.logger.trace(
"TorrentSmartContainer: No matching file found in full_index, item not added to best matching"
)
else:
# No full_index available, add series torrent anyway - AllDebrid will extract files
best_matching.append(torrent_item)
self.logger.trace(
f"TorrentSmartContainer: Item added to best matching (found matching file: {matching_file['file_name']})"
)
else:
self.logger.trace(
"TorrentSmartContainer: No matching file found, item not added to best matching"
"TorrentSmartContainer: Item added to best matching (series without full_index, will be extracted by debrid)"
)
else:
# For movies without file_index, still add them
best_matching.append(torrent_item)
self.logger.trace(
"TorrentSmartContainer: Item added to best matching (movie without file_index)"
)
else:
# Toujours inclure tous les torrents DMM - API et autres
best_matching.append(torrent_item)
@ -100,6 +113,16 @@ class TorrentSmartContainer:
self.logger.trace(
f"TorrentSmartContainer: Item added to best matching (magnet link) - {seeders_info}"
)
# Ensure all items have parsed_data before returning
for item in best_matching:
if item.parsed_data is None:
self.logger.debug(
f"TorrentSmartContainer.get_best_matching: Item '{item.raw_title[:60]}' missing parsed_data, parsing now"
)
from RTN import parse
item.parsed_data = parse(item.raw_title)
self.logger.success(
f"TorrentSmartContainer: Found {len(best_matching)} best matching items"
)
@ -332,7 +355,7 @@ class TorrentSmartContainer:
processed_files.append(
{
"file_index": index,
"title": file["name"],
"title": os.path.basename(file["name"]),
"size": file["size"],
}
)
@ -340,7 +363,7 @@ class TorrentSmartContainer:
processed_files.append(
{
"file_index": index,
"title": file["name"],
"title": os.path.basename(file["name"]),
"size": file["size"],
}
)
@ -466,7 +489,8 @@ class TorrentSmartContainer:
is_season_pack = item.type == "series" and len(video_files) > 5
if is_season_pack:
self.logger.info(f"TorrentSmartContainer: Détection d'un pack de saison avec {len(video_files)} fichiers vidéo pour {item.raw_title}")
self.logger.debug(f"TorrentSmartContainer: Détection d'un pack de saison avec {len(video_files)} fichiers vidéo pour {item.raw_title}")
self.logger.debug(f"TorrentSmartContainer: Video files detected: {[f.get("name") for f in video_files]}")
if item.type == "series":
# Version originale simple : chercher les fichiers qui correspondent à l'épisode
@ -491,11 +515,11 @@ class TorrentSmartContainer:
self.logger.debug(f"TorrentSmartContainer: Match found: {file_name}")
if matching_files:
self._update_file_details(item, matching_files, debrid=debrid_code)
self._update_file_details(item, matching_files, debrid=debrid_code, skip_file_name_for_series=False)
else:
self.logger.debug(f"TorrentSmartContainer: No matching episode files found for {item.raw_title}")
from stream_fusion.utils.general import smart_episode_fallback
fallback_file = smart_episode_fallback(files, numeric_season, numeric_episode)
if fallback_file:
file_info = {
@ -503,7 +527,7 @@ class TorrentSmartContainer:
"title": fallback_file.get("name", ""),
"size": fallback_file.get("size", 0),
}
self._update_file_details(item, [file_info], debrid=debrid_code)
self._update_file_details(item, [file_info], debrid=debrid_code, skip_file_name_for_series=False)
self.logger.info(f"TorrentSmartContainer: Fallback intelligent utilisé pour {item.raw_title}: {fallback_file.get('name')}")
else:
# Vraiment aucun fichier trouvé - marquer comme disponible mais sans file_index
@ -536,7 +560,7 @@ class TorrentSmartContainer:
"TorrentSmartContainer: StremThru availability update completed"
)
def _update_file_details(self, torrent_item, files, debrid: str = "??"):
def _update_file_details(self, torrent_item, files, debrid: str = "??", skip_file_name_for_series=False):
if not files:
self.logger.debug(
f"TorrentSmartContainer: No files to update for {torrent_item.raw_title}"
@ -544,8 +568,14 @@ class TorrentSmartContainer:
return
file = max(files, key=lambda file: file["size"])
torrent_item.availability = debrid
torrent_item.file_index = file["file_index"]
torrent_item.file_name = file["title"]
# Only update file_index if it wasn't set to None for intelligent selection
if torrent_item.file_index is not None:
torrent_item.file_index = file["file_index"]
# For series, assign file title to raw_title to avoid StremThru duplication
if torrent_item.type == "series":
torrent_item.raw_title = file["title"]
else:
torrent_item.file_name = file["title"]
torrent_item.size = file["size"]
self.logger.debug(
f"TorrentSmartContainer: Updated file details for {torrent_item.raw_title}: {file['title']}"
@ -562,9 +592,17 @@ class TorrentSmartContainer:
self.logger.debug(f"Adding {item.info_hash} to items dict")
items_dict[item.info_hash] = item
else:
self.logger.debug(
f"TorrentSmartContainer: Skipping duplicate info hash: {item.info_hash}"
)
# Garder Yggtorrent si présent, sinon garder le premier
existing_item = items_dict[item.info_hash]
if item.indexer == "Yggtorrent - API" and existing_item.indexer != "Yggtorrent - API":
self.logger.debug(
f"TorrentSmartContainer: Replacing {existing_item.indexer} with Yggtorrent for hash: {item.info_hash}"
)
items_dict[item.info_hash] = item
else:
self.logger.debug(
f"TorrentSmartContainer: Skipping duplicate info hash: {item.info_hash} (keeping {existing_item.indexer})"
)
self.logger.info(
f"TorrentSmartContainer: Built dictionary with {len(items_dict)} unique items"
)

View file

@ -8,27 +8,7 @@ from stream_fusion.logging_config import logger
class YggflixAPI:
def __init__(
self,
pool_connections=10,
pool_maxsize=10,
max_retries=3,
timeout=10,
):
"""
Initialize the YggflixAPI class.
This constructor sets up the API client with connection pooling and retry strategies.
Args:
pool_connections (int): Number of connection pools to cache. Defaults to 10.
pool_maxsize (int): Maximum number of connections to save in the pool. Defaults to 10.
max_retries (int): Maximum number of retries for failed requests. Defaults to 3.
timeout (int): Timeout for requests in seconds. Defaults to 10.
Note:
The base URL for the API is set using the settings.yggflix_url value.
"""
def __init__(self, pool_connections=10, pool_maxsize=50, max_retries=0, timeout=5):
self.base_url = f"{settings.yggflix_url}/api"
self.timeout = timeout
self.session = requests.Session()
@ -39,39 +19,14 @@ class YggflixAPI:
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS"],
)
adapter = HTTPAdapter(
pool_connections=pool_connections,
pool_maxsize=pool_maxsize,
max_retries=retry_strategy,
)
adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
def _make_request(self, method, endpoint, params=None):
"""
Make an HTTP request to the API.
This method handles the actual HTTP request, including error handling and logging.
Args:
method (str): HTTP method (GET, POST, etc.)
endpoint (str): API endpoint (the part of the URL after the base URL)
params (dict, optional): Query parameters to include in the request. Defaults to None.
Returns:
dict: JSON response from the API
Raises:
requests.exceptions.HTTPError: If an HTTP error occurs
requests.exceptions.ConnectionError: If a connection error occurs
requests.exceptions.Timeout: If the request times out
requests.exceptions.RequestException: For any other request-related errors
"""
url = f"{self.base_url}{endpoint}"
try:
response = self.session.request(
method, url, params=params, timeout=self.timeout
)
response = self.session.request(method, url, params=params, timeout=self.timeout)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
@ -88,136 +43,38 @@ class YggflixAPI:
raise
def search(self, query=""):
"""
Perform a search on the API.
Args:
query (str, optional): The search query. Defaults to an empty string.
Returns:
dict: JSON response containing search results
"""
return self._make_request("GET", "/search", params={"q": query})
def get_home(self):
"""
Get home page data from the API.
Returns:
dict: JSON response containing home page data
"""
return self._make_request("GET", "/home")
def get_movie_detail(self, movie_id: int):
"""
Get details of a specific movie.
Args:
movie_id (int): The unique identifier of the movie
Returns:
dict: JSON response containing movie details
"""
return self._make_request("GET", f"/movie/{movie_id}")
def get_movie_torrents(self, movie_id: int):
"""
Get torrents associated with a specific movie.
Args:
movie_id (int): The unique identifier of the movie
Returns:
dict: JSON response containing torrent information for the movie
"""
return self._make_request("GET", f"/movie/{movie_id}/torrents")
def get_tvshow_detail(self, tvshow_id: int):
"""
Get details of a specific TV show.
Args:
tvshow_id (int): The unique identifier of the TV show
Returns:
dict: JSON response containing TV show details
"""
return self._make_request("GET", f"/tvshow/{tvshow_id}")
def get_tvshow_torrents(self, tvshow_id: int):
"""
Get torrents associated with a specific TV show.
Args:
tvshow_id (int): The unique identifier of the TV show
Returns:
dict: JSON response containing torrent information for the TV show
"""
return self._make_request("GET", f"/tvshow/{tvshow_id}/torrents")
def get_torrents(
self,
page: int = 1,
q: str = "",
category_id: Optional[int] = None,
order_by: str = "uploaded_at",
) -> List[dict]:
"""
Get a list of torrents with optional filtering and sorting.
Args:
page (int): Page number for pagination. Defaults to 1.
q (str): Search query. Defaults to an empty string.
category_id (int, optional): Category ID for filtering. Defaults to None.
order_by (str): Field to order results by. Can be "uploaded_at", "seeders", or "downloads".
Defaults to "uploaded_at".
Returns:
List[dict]: A list of torrent results.
"""
def get_torrents(self, page: int = 1, q: str = "", category_id: Optional[int] = None, order_by: str = "uploaded_at") -> List[dict]:
params = {"page": page, "q": q, "order_by": order_by}
if category_id is not None:
params["category_id"] = category_id
return self._make_request("GET", "/torrents", params=params)
def get_torrent_detail(self, torrent_id: int) -> dict:
"""
Get detailed information about a specific torrent.
Args:
torrent_id (int): The unique identifier of the torrent.
Returns:
dict: Detailed information about the torrent.
"""
return self._make_request("GET", f"/torrent/{torrent_id}")
def download_torrent(self, torrent_id: int, passkey: str) -> bytes:
"""
Download a specific torrent file.
Args:
torrent_id (int): The unique identifier of the torrent to download.
passkey (str): A 32-character passkey for authentication.
Returns:
bytes: The raw content of the .torrent file.
Raises:
ValueError: If the passkey is not exactly 32 characters long.
requests.exceptions.HTTPError: If an HTTP error occurs during the download.
requests.exceptions.RequestException: For any other request-related errors.
"""
if len(passkey) != 32:
raise ValueError("Passkey must be exactly 32 characters long.")
url = f"{self.base_url}/torrent/{torrent_id}/download"
params = {"passkey": passkey}
try:
response = self.session.get(url, params=params, timeout=self.timeout)
response = self.session.get(url, params={"passkey": passkey}, timeout=self.timeout)
response.raise_for_status()
return response.content
except requests.exceptions.HTTPError as e:
@ -228,10 +85,4 @@ class YggflixAPI:
raise
def __del__(self):
"""
Close the session when the object is destroyed.
This destructor ensures that the requests session is properly closed,
freeing up system resources.
"""
self.session.close()

View file

@ -7,32 +7,35 @@ from stream_fusion.utils.detection import detect_languages
class YggflixResult:
def __init__(self):
self.raw_title = None # Raw title of the torrent
self.size = None # Size of the torrent
self.link = None # Contruct a magnet link here
self.indexer = None # Indexer
self.seeders = None # Seeders count
self.magnet = None # Magnet url
self.info_hash = None # infoHash by Jackett
self.privacy = None # public or private
self.raw_title = None
self.size = None
self.link = None
self.indexer = None
self.seeders = None
self.magnet = None
self.info_hash = None
self.privacy = None
self.from_cache = None
self.languages = None # Language of the torrent
self.type = None # series or movie
self.parsed_data = None # Ranked result
self.languages = None
self.type = None
self.tmdb_id = None
self.parsed_data = None
def convert_to_torrent_item(self):
parsed_data = self.parsed_data or parse(self.raw_title)
logger.debug(f"YggflixResult.convert_to_torrent_item(): '{self.raw_title[:60]}' → resolution='{getattr(parsed_data, 'resolution', 'UNKNOWN')}'")
return TorrentItem(
self.raw_title,
self.size,
self.magnet,
self.info_hash.lower() if self.info_hash is not None else None,
self.link,
self.seeders,
self.languages,
self.indexer,
self.privacy,
self.type,
self.parsed_data
raw_title=self.raw_title,
size=self.size,
magnet=self.magnet,
info_hash=self.info_hash.lower() if self.info_hash is not None else None,
link=self.link,
seeders=self.seeders,
languages=self.languages,
indexer=self.indexer,
privacy=self.privacy,
type=self.type,
parsed_data=parsed_data,
torrent_download=None,
tmdb_id=self.tmdb_id
)

View file

@ -1,4 +1,6 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Union
from urllib.parse import quote
from RTN import parse
from stream_fusion.logging_config import logger
@ -6,35 +8,16 @@ from stream_fusion.utils.detection import detect_languages
from stream_fusion.utils.yggfilx.yggflix_result import YggflixResult
from stream_fusion.utils.models.movie import Movie
from stream_fusion.utils.models.series import Series
from stream_fusion.settings import settings
from stream_fusion.utils.yggfilx.yggflix_api import YggflixAPI
class YggflixService:
"""Service for searching media on Yggflix."""
def __init__(self, config: dict):
self.yggflix = YggflixAPI()
self.has_tmdb = config.get("metadataProvider") == "tmdb"
if settings.ygg_unique_account and settings.ygg_passkey:
self.ygg_passkey = settings.ygg_passkey
else:
self.ygg_passkey = config.get("yggPasskey")
def search(self, media: Union[Movie, Series]) -> List[YggflixResult]:
"""
Search for a media (movie or series) on Yggflix.
Args:
media (Union[Movie, Series]): The media to search for.
Returns:
List[YggflixResult]: List of search results.
Raises:
TypeError: If the media type is neither Movie nor Series.
"""
if isinstance(media, Movie):
results = self.__search_movie(media)
elif isinstance(media, Series):
@ -45,72 +28,92 @@ class YggflixService:
return self.__post_process_results(results, media)
def __filter_out_no_seeders(self, results: List[dict]) -> List[dict]:
"""Filter out results with less than 5 seeders."""
return [result for result in results if result.get("seeders", 0) >= 0]
def __process_download_link(self, id: int) -> str:
"""Generate the download link for a given torrent."""
if settings.yggflix_url:
return f"{settings.yggflix_url}/api/torrent/{id}/download?passkey={self.ygg_passkey}"
def __build_magnet(self, info_hash: str, title: str) -> str:
return f"magnet:?xt=urn:btih:{info_hash}&dn={quote(title)}"
def __search_movie(self, media: Movie) -> List[dict]:
"""Search for a movie on Yggflix."""
if not self.has_tmdb:
raise ValueError("Please use TMDB metadata provider for Yggflix")
try:
logger.info(f"Searching Yggflix for movie: {media.titles[0]}")
return self.yggflix.get_movie_torrents(media.tmdb_id)
except Exception as e:
logger.error(
f"Error searching Yggflix for movie: {media.titles[0]}", exc_info=True
)
return []
logger.info(f"Searching Yggflix for movie: {media.titles[0]}")
return self.yggflix.get_movie_torrents(media.tmdb_id)
def __search_series(self, media: Series) -> List[dict]:
"""Search for a series on Yggflix."""
if not self.has_tmdb:
raise ValueError("Please use TMDB metadata provider for Yggflix")
logger.info(f"Searching Yggflix for series: {media.titles[0]}")
return self.yggflix.get_tvshow_torrents(int(media.tmdb_id))
def __filter_series_results(self, results: List[dict], media: Series) -> List[dict]:
season_num = media.get_season_number()
episode_num = media.get_episode_number()
filtered = []
for r in results:
r_season = r.get("season")
r_episode = r.get("episode")
if r_season is None:
filtered.append(r)
elif r_season == season_num and r_episode is None:
filtered.append(r)
elif r_season == season_num and r_episode == episode_num:
filtered.append(r)
if filtered:
logger.debug(f"Yggflix: pre-filtered {len(results)}{len(filtered)} results for {media.season}{media.episode}")
else:
logger.debug(f"Yggflix: pre-filter found nothing, keeping all {len(results)} results")
filtered = results
return filtered
def __fetch_detail(self, result: dict) -> tuple:
torrent_id = result.get("id")
if not torrent_id:
return result, None
try:
logger.info(f"Searching Yggflix for series: {media.titles[0]}")
return self.yggflix.get_tvshow_torrents(int(media.tmdb_id))
detail = self.yggflix.get_torrent_detail(torrent_id)
return result, detail.get("hash")
except Exception as e:
logger.error(
f"Error searching Yggflix for series: {media.titles[0]}", exc_info=True
)
return []
logger.warning(f"Yggflix: failed to get detail for torrent {torrent_id}: {e}")
return result, None
def __post_process_results(
self, results: List[dict], media: Union[Movie, Series]
) -> List[YggflixResult]:
"""Process raw search results and convert them to YggflixResult objects."""
if not results:
logger.info(f"No results found on Yggflix for: {media.titles[0]}")
return []
results = self.__filter_out_no_seeders(results)
logger.info(f"{len(results)} results found on Yggflix for: {media.titles[0]}")
if isinstance(media, Series):
results = self.__filter_series_results(results, media)
results = sorted(results, key=lambda r: r.get("seeders", 0), reverse=True)[:50]
logger.info(f"{len(results)} results to fetch from Yggflix for: {media.titles[0]}")
items = []
for result in results:
item = YggflixResult()
item.raw_title=result["title"]
item.size=result.get("size", 0)
item.link=(
self.__process_download_link(result.get("id"))
if result.get("id")
else None
)
item.indexer="Yggtorrent - API"
item.seeders=result.get("seeders", 0)
item.privacy="private"
item.languages=detect_languages(item.raw_title, default_language="fr")
item.type=media.type
item.parsed_data=parse(item.raw_title)
items.append(item)
logger.trace(f"Yggflix result: {item}")
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(self.__fetch_detail, r): r for r in results}
for future in as_completed(futures):
result, info_hash = future.result()
if not info_hash:
continue
item = YggflixResult()
item.raw_title = result["title"]
item.size = result.get("size", 0)
item.info_hash = info_hash.lower()
item.magnet = self.__build_magnet(info_hash, result["title"])
item.link = item.magnet
item.indexer = "Yggtorrent - API"
item.seeders = result.get("seeders", 0)
item.privacy = "private"
item.languages = detect_languages(item.raw_title, default_language="fr")
item.type = media.type
item.parsed_data = parse(item.raw_title)
item.tmdb_id = media.tmdb_id
items.append(item)
logger.trace(f"Yggflix result: {item}")
return items

View file

@ -1,15 +1,16 @@
import requests
import asyncio
import aiohttp
from typing import List, Optional, Tuple, Dict, Any
from pydantic import BaseModel, ConfigDict, Field
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from stream_fusion.settings import settings
from stream_fusion.logging_config import logger
import time
class DMMQueryRequest(BaseModel):
queryText: Optional[str] = None
class DMMImdbFile(BaseModel):
imdbId: Optional[str] = None
category: Optional[str] = None
@ -17,6 +18,7 @@ class DMMImdbFile(BaseModel):
adult: Optional[bool] = None
year: Optional[int] = None
class DMMImdbSearchResult(BaseModel):
title: Optional[str] = None
imdbId: Optional[str] = None
@ -24,6 +26,7 @@ class DMMImdbSearchResult(BaseModel):
score: float = 0.0
category: Optional[str] = None
class DMMTorrentInfo(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@ -76,37 +79,34 @@ class DMMTorrentInfo(BaseModel):
imdb_id: Optional[str] = None
imdb: Optional[DMMImdbFile] = None
class ZileanAPI:
def __init__(
self,
pool_connections: int = settings.zilean_pool_connections,
pool_maxsize: int = settings.zilean_api_pool_maxsize,
max_retries: int = settings.zilean_max_retry,
):
def __init__(self, session: Optional[aiohttp.ClientSession] = None):
self.base_url = settings.zilean_url
if not self.base_url:
logger.error("Zilean API URL is not set in the environment variables.")
raise ValueError("Zilean API URL is not set in the environment variables.")
self.session = requests.Session()
retry_strategy = Retry(
total=max_retries,
backoff_factor=0.1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"],
)
adapter = HTTPAdapter(
pool_connections=pool_connections,
pool_maxsize=pool_maxsize,
max_retries=retry_strategy,
)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
self._external_session = session is not None
self._session = session
self._timeout = aiohttp.ClientTimeout(total=30)
# Cache pour les résultats de recherche
self._cache: Dict[str, Dict[str, Any]] = {}
self._cache_ttl = 900 # 15 minutes en secondes
async def _get_session(self) -> aiohttp.ClientSession:
"""Retourne la session aiohttp, en crée une si nécessaire."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(timeout=self._timeout)
self._external_session = False
return self._session
async def close(self):
"""Ferme la session si elle a été créée en interne."""
if self._session and not self._external_session and not self._session.closed:
await self._session.close()
def _get_cache_key(self, method: str, endpoint: str, **kwargs) -> str:
"""Génère une clé de cache unique basée sur la méthode, l'endpoint et les paramètres."""
params_str = "&".join(f"{k}={v}" for k, v in sorted(kwargs.items()) if v is not None)
@ -128,51 +128,55 @@ class ZileanAPI:
"data": data,
"timestamp": time.time()
}
# Nettoyer le cache si trop volumineux (garder max 100 entrées)
if len(self._cache) > 100:
# Supprimer la plus ancienne entrée
oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k]["timestamp"])
del self._cache[oldest_key]
def _request(self, method: str, endpoint: str, cache: bool = True, **kwargs):
async def _request(self, method: str, endpoint: str, cache: bool = True, **kwargs) -> Any:
"""
Effectue une requête HTTP avec gestion de cache.
Effectue une requête HTTP async avec gestion de cache.
:param method: Méthode HTTP (GET, POST, etc.)
:param endpoint: Point d'accès API
:param cache: Activer/désactiver le cache pour cette requête
:param kwargs: Arguments supplémentaires pour la requête
:return: Réponse HTTP
:return: Données JSON de la réponse
"""
url = f"{self.base_url}{endpoint}"
headers = kwargs.pop("headers", {})
headers.update(
{"accept": "application/json", "Content-Type": "application/json"}
)
# Vérifier le cache pour les requêtes GET si activé
params = kwargs.get("params", {})
if cache and method.upper() == "GET":
cache_key = self._get_cache_key(method, endpoint, **kwargs)
cache_key = self._get_cache_key(method, endpoint, **params)
cached_data = self._get_from_cache(cache_key)
if cached_data:
return type('CachedResponse', (), {'json': lambda: cached_data, 'text': str(cached_data)})
return cached_data
session = await self._get_session()
try:
response = self.session.request(method, url, headers=headers, **kwargs)
response.raise_for_status()
# Mettre en cache les résultats pour les requêtes GET si activé
if cache and method.upper() == "GET":
try:
cache_key = self._get_cache_key(method, endpoint, **kwargs)
self._add_to_cache(cache_key, response.json())
except Exception as e:
logger.warning(f"Impossible de mettre en cache la réponse: {e}")
return response
except requests.exceptions.RequestException as e:
logger.error(f"Erreur lors de la requête API : {e}")
async with session.request(method, url, headers=headers, **kwargs) as response:
response.raise_for_status()
data = await response.json()
# Mettre en cache les résultats pour les requêtes GET si activé
if cache and method.upper() == "GET":
try:
cache_key = self._get_cache_key(method, endpoint, **params)
self._add_to_cache(cache_key, data)
except Exception as e:
logger.warning(f"Impossible de mettre en cache la réponse: {e}")
return data
except aiohttp.ClientError as e:
logger.error(f"Erreur lors de la requête API Zilean: {e}")
raise
def _convert_to_dmm_torrent_info(self, entry: dict) -> DMMTorrentInfo:
@ -183,11 +187,11 @@ class ZileanAPI:
entry['imdb'] = DMMImdbFile(**entry['imdb'])
return DMMTorrentInfo(**entry)
def dmm_search(self, query: DMMQueryRequest) -> List[DMMTorrentInfo]:
response = self._request("POST", "/dmm/search", json=query.dict())
return [self._convert_to_dmm_torrent_info(entry) for entry in response.json()]
async def dmm_search(self, query: DMMQueryRequest) -> List[DMMTorrentInfo]:
data = await self._request("POST", "/dmm/search", json=query.dict())
return [self._convert_to_dmm_torrent_info(entry) for entry in data]
def dmm_filtered(
async def dmm_filtered(
self,
query: Optional[str] = None,
season: Optional[int] = None,
@ -207,23 +211,27 @@ class ZileanAPI:
"ImdbId": imdb_id,
}
params = {k: v for k, v in params.items() if v is not None}
response = self._request("GET", "/dmm/filtered", params=params)
return [self._convert_to_dmm_torrent_info(entry) for entry in response.json()]
data = await self._request("GET", "/dmm/filtered", params=params)
return [self._convert_to_dmm_torrent_info(entry) for entry in data]
def dmm_on_demand_scrape(self) -> None:
self._request("GET", "/dmm/on-demand-scrape", cache=False)
async def dmm_on_demand_scrape(self) -> None:
await self._request("GET", "/dmm/on-demand-scrape", cache=False)
def healthchecks_ping(self) -> str:
response = self._request("GET", "/healthchecks/ping", cache=False)
return response.text
async def healthchecks_ping(self) -> str:
session = await self._get_session()
url = f"{self.base_url}/healthchecks/ping"
async with session.get(url) as response:
return await response.text()
def imdb_search(
async def imdb_search(
self, query: Optional[str] = None, year: Optional[int] = None, category: Optional[str] = None
) -> List[DMMImdbSearchResult]:
params = {"Query": query, "Year": year, "Category": category}
params = {k: v for k, v in params.items() if v is not None}
response = self._request("POST", "/imdb/search", params=params)
return [DMMImdbSearchResult(**file) for file in response.json()]
data = await self._request("POST", "/imdb/search", params=params)
return [DMMImdbSearchResult(**file) for file in data]
def __del__(self):
self.session.close()
# Note: Ne pas fermer la session dans __del__ car c'est async
# Utiliser close() explicitement ou un context manager
pass

View file

@ -1,7 +1,6 @@
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
import asyncio
import aiohttp
from typing import List, Union, Dict, Tuple, Optional
from functools import lru_cache
import time
from stream_fusion.logging_config import logger
@ -10,29 +9,30 @@ from stream_fusion.utils.models.series import Series
from stream_fusion.settings import settings
from stream_fusion.utils.zilean.zilean_api import ZileanAPI, DMMQueryRequest, DMMTorrentInfo
class ZileanService:
def __init__(self, config):
self.zilean_api = ZileanAPI()
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
self.zilean_api = ZileanAPI(session=session)
self.logger = logger
self.max_workers = settings.zilean_max_workers
self._search_cache: Dict[str, Tuple[List[DMMTorrentInfo], float]] = {}
self._cache_ttl = 3600 # 1 heure en secondes
def search(self, media: Union[Movie, Series]) -> List[DMMTorrentInfo]:
async def search(self, media: Union[Movie, Series]) -> List[DMMTorrentInfo]:
# Vérifier si nous avons déjà des résultats en cache pour ce média
cache_key = self._get_cache_key(media)
cached_results = self._get_from_cache(cache_key)
if cached_results:
return cached_results
# Sinon, effectuer la recherche
if isinstance(media, Movie):
results = self.__search_movie(media)
results = await self.__search_movie(media)
elif isinstance(media, Series):
results = self.__search_series(media)
results = await self.__search_series(media)
else:
raise TypeError("Only Movie and Series are allowed as media!")
# Stocker les résultats dans le cache
self._add_to_cache(cache_key, results)
return results
@ -59,7 +59,7 @@ class ZileanService:
def _add_to_cache(self, cache_key: str, results: List[DMMTorrentInfo]) -> None:
"""Ajoute des résultats au cache avec un timestamp."""
self._search_cache[cache_key] = (results, time.time())
# Nettoyer le cache si trop volumineux (garder max 50 entrées)
if len(self._search_cache) > 50:
# Supprimer la plus ancienne entrée
@ -84,92 +84,96 @@ class ZileanService:
seen = set()
return [title for title in titles if not (title.lower() in seen or seen.add(title.lower()))]
def __search_movie(self, movie: Movie) -> List[DMMTorrentInfo]:
async def __search_movie(self, movie: Movie) -> List[DMMTorrentInfo]:
unique_titles = self.__remove_duplicate_titles(movie.titles)
# Recherche par IMDb ID d'abord (souvent plus précise)
imdb_results = self.__search_by_imdb_id(movie.id)
imdb_results = await self.__search_by_imdb_id(movie.id)
# Si nous avons suffisamment de résultats IMDb, nous pouvons éviter des recherches supplémentaires
if len(imdb_results) >= 10:
return imdb_results
# Sinon, compléter avec des recherches par titre
keyword_results = self.__threaded_search_movie(unique_titles)
# Sinon, compléter avec des recherches par titre en parallèle
keyword_results = await self.__parallel_search_movie(unique_titles)
# Combiner et dédupliquer les résultats
all_results = imdb_results + keyword_results
return self.__deduplicate_api_results(all_results)
def __search_series(self, series: Series) -> List[DMMTorrentInfo]:
async def __search_series(self, series: Series) -> List[DMMTorrentInfo]:
unique_titles = self.__remove_duplicate_titles(series.titles)
# Recherche par IMDb ID d'abord (souvent plus précise)
imdb_results = self.__search_by_imdb_id(series.id)
imdb_results = await self.__search_by_imdb_id(series.id)
# Si nous avons suffisamment de résultats IMDb, nous pouvons éviter des recherches supplémentaires
if len(imdb_results) >= 10:
return imdb_results
# Sinon, compléter avec des recherches par titre
keyword_results = self.__threaded_search_series(unique_titles, series)
# Sinon, compléter avec des recherches par titre en parallèle
keyword_results = await self.__parallel_search_series(unique_titles, series)
# Combiner et dédupliquer les résultats
all_results = imdb_results + keyword_results
return self.__deduplicate_api_results(all_results)
def __threaded_search_movie(self, search_texts: List[str]) -> List[DMMTorrentInfo]:
results = []
async def __parallel_search_movie(self, search_texts: List[str]) -> List[DMMTorrentInfo]:
"""Recherche parallèle avec asyncio.gather()."""
if not search_texts:
return results
# Limite le nombre de titres à rechercher pour éviter trop de requêtes
search_texts = search_texts[:3]
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_to_text = {executor.submit(self.__make_movie_request, text): text for text in search_texts}
for future in as_completed(future_to_text):
try:
results.extend(future.result())
except Exception as e:
self.logger.exception(f"Error in threaded movie search: {str(e)}")
return results
return []
def __threaded_search_series(self, search_texts: List[str], series: Series) -> List[DMMTorrentInfo]:
results = []
if not search_texts:
return results
# Limite le nombre de titres à rechercher pour éviter trop de requêtes
search_texts = search_texts[:3]
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_to_text = {executor.submit(self.__make_series_request, text, series): text for text in search_texts}
for future in as_completed(future_to_text):
try:
results.extend(future.result())
except Exception as e:
self.logger.exception(f"Error in threaded series search: {str(e)}")
tasks = [self.__make_movie_request(text) for text in search_texts]
results_lists = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for result in results_lists:
if isinstance(result, Exception):
self.logger.exception(f"Error in parallel movie search: {result}")
elif result:
results.extend(result)
return results
def __make_movie_request(self, query_text: str) -> List[DMMTorrentInfo]:
async def __parallel_search_series(self, search_texts: List[str], series: Series) -> List[DMMTorrentInfo]:
"""Recherche parallèle avec asyncio.gather()."""
if not search_texts:
return []
# Limite le nombre de titres à rechercher pour éviter trop de requêtes
search_texts = search_texts[:3]
tasks = [self.__make_series_request(text, series) for text in search_texts]
results_lists = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for result in results_lists:
if isinstance(result, Exception):
self.logger.exception(f"Error in parallel series search: {result}")
elif result:
results.extend(result)
return results
async def __make_movie_request(self, query_text: str) -> List[DMMTorrentInfo]:
try:
return self.zilean_api.dmm_search(DMMQueryRequest(queryText=query_text))
return await self.zilean_api.dmm_search(DMMQueryRequest(queryText=query_text))
except Exception as e:
self.logger.exception(f"An exception occurred while searching for movie '{query_text}' on Zilean: {str(e)}")
return []
def __make_series_request(self, query_text: str, series: Series) -> List[DMMTorrentInfo]:
async def __make_series_request(self, query_text: str, series: Series) -> List[DMMTorrentInfo]:
try:
season = getattr(series, 'season', None)
episode = getattr(series, 'episode', None)
if season is not None:
season = season.lstrip('S') if isinstance(season, str) else season
if episode is not None:
episode = episode.lstrip('E') if isinstance(episode, str) else episode
return self.zilean_api.dmm_filtered(
return await self.zilean_api.dmm_filtered(
query=query_text,
season=season,
episode=episode
@ -178,9 +182,13 @@ class ZileanService:
self.logger.exception(f"An exception occurred while searching for series '{query_text}' on Zilean: {str(e)}")
return []
def __search_by_imdb_id(self, imdb_id: str) -> List[DMMTorrentInfo]:
async def __search_by_imdb_id(self, imdb_id: str) -> List[DMMTorrentInfo]:
try:
return self.zilean_api.dmm_filtered(imdb_id=imdb_id)
return await self.zilean_api.dmm_filtered(imdb_id=imdb_id)
except Exception as e:
self.logger.exception(f"An exception occurred while searching for IMDb ID '{imdb_id}' on Zilean: {str(e)}")
return []
async def close(self):
"""Ferme les ressources."""
await self.zilean_api.close()

View file

@ -12,7 +12,6 @@ from stream_fusion.logging_config import configure_logging
from stream_fusion.services.postgresql.base import Base
from stream_fusion.services.postgresql.models import load_all_models
from stream_fusion.settings import settings
from stream_fusion.services.postgresql.utils import init_db_cleanup_function
def _setup_db(app: FastAPI) -> None: # pragma: no cover
@ -25,7 +24,7 @@ def _setup_db(app: FastAPI) -> None: # pragma: no cover
:param app: fastAPI application.
"""
engine = create_async_engine(str(settings.pg_url), echo=settings.pg_echo)
engine = create_async_engine(str(settings.pg_url), echo=settings.pg_echo, pool_size=settings.pg_pool_size, max_overflow=settings.pg_max_overflow)
session_factory = async_sessionmaker(
engine,
expire_on_commit=False,
@ -33,8 +32,6 @@ def _setup_db(app: FastAPI) -> None: # pragma: no cover
app.state.db_engine = engine
app.state.db_session_factory = session_factory
# Need to check if we can add pg_cron to the zileanDB
# init_db_cleanup_function(engine)
@asynccontextmanager
@ -47,17 +44,8 @@ async def lifespan_setup(
:param app: the FastAPI application.
:yield: None
"""
# Startup actions
app.middleware_stack = None
_setup_db(app)
# # Load all models from postgresql/models
# load_all_models()
# # Create tables
# async with app.state.db_engine.begin() as conn:
# await conn.run_sync(Base.metadata.create_all)
app.middleware_stack = app.build_middleware_stack()
if settings.playback_proxy and settings.proxy_url:
@ -72,8 +60,16 @@ async def lifespan_setup(
timeout = aiohttp.ClientTimeout(total=settings.aiohttp_timeout)
app.state.http_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
# Session dédiée aux services debrid (avec proxy si configuré)
if settings.proxy_url:
debrid_connector = ProxyConnector.from_url(str(settings.proxy_url), limit=100, limit_per_host=50)
else:
debrid_connector = aiohttp.TCPConnector(limit=100, limit_per_host=50)
debrid_timeout = aiohttp.ClientTimeout(total=30)
app.state.debrid_session = aiohttp.ClientSession(timeout=debrid_timeout, connector=debrid_connector)
app.state.redis_pool = ConnectionPool(
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db, max_connections=50
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db, max_connections=200
)
yield
@ -81,6 +77,8 @@ async def lifespan_setup(
# Shutdown actions
if app.state.http_session:
await app.state.http_session.close()
if app.state.debrid_session:
await app.state.debrid_session.close()
if app.state.redis_pool:
app.state.redis_pool.disconnect()
await app.state.db_engine.dispose()
await app.state.db_engine.dispose()

View file

@ -7,8 +7,6 @@ import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from redis.exceptions import LockError
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi_simple_rate_limiter import rate_limiter
from fastapi_simple_rate_limiter.database import create_redis_session
from starlette.background import BackgroundTask
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
@ -22,6 +20,8 @@ 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.debrid_exceptions import DebridError
from stream_fusion.utils.debrid.status_video import build_status_video_response, get_status_video_url
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
@ -35,13 +35,32 @@ router = APIRouter()
redis_client = redis.Redis(
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db
)
redis_session = create_redis_session(
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db
)
DOWNLOAD_IN_PROGRESS_FLAG = "DOWNLOAD_IN_PROGRESS"
def get_client_ip(request: Request) -> str:
"""Extract real client IP from headers (X-Forwarded-For) or fallback to direct connection IP."""
# Check X-Forwarded-For header (set by Traefik/reverse proxy)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# X-Forwarded-For can be comma-separated list, take the first (original client)
client_ip = forwarded_for.split(",")[0].strip()
logger.debug(f"Client IP extracted from X-Forwarded-For: {client_ip}")
return client_ip
# Check X-Real-IP header (alternative header used by some proxies)
real_ip = request.headers.get("X-Real-IP")
if real_ip:
logger.debug(f"Client IP extracted from X-Real-IP: {real_ip}")
return real_ip
# Fallback to direct connection IP
direct_ip = request.client.host
logger.debug(f"Client IP extracted from direct connection: {direct_ip}")
return direct_ip
class ProxyStreamer:
def __init__(
self,
@ -113,13 +132,10 @@ class ProxyStreamer:
async def handle_download(
query: dict, config: dict, ip: str, redis_cache: RedisCache
query: dict, config: dict, ip: str, redis_cache: RedisCache, debrid_session=None
) -> str:
api_key = config.get("apiKey")
cache_key = f"download:{api_key}:{json.dumps(query)}_{ip}"
stremthru_link_key = f"stremthru_link:{api_key}:{json.dumps(query)}_{ip}"
ready_cache_key = f"ready:{api_key}:{json.dumps(query)}_{ip}"
if await redis_cache.get(ready_cache_key) == "READY":
logger.info("Playback: File already marked as ready, checking for cached direct link")
@ -131,12 +147,12 @@ async def handle_download(
logger.info("Playback: Direct link found in cache, returning immediately")
return cached_direct_link
debrid_service = get_download_service(config)
debrid_service = get_download_service(config, debrid_session)
if debrid_service:
try:
direct_link = debrid_service.get_stream_link(query, config, ip)
if direct_link and direct_link != settings.no_cache_video_url:
await redis_cache.set(direct_link_cache_key, direct_link, expiration=600)
direct_link = await debrid_service.get_stream_link(query, config, ip)
if direct_link and direct_link != settings.no_cache_video_url:
await redis_cache.set(direct_link_cache_key, direct_link, expiration=600)
logger.info("Playback: Direct link generated and cached")
return direct_link
except Exception:
@ -145,54 +161,30 @@ async def handle_download(
download_flag = await redis_cache.get(cache_key)
if download_flag == DOWNLOAD_IN_PROGRESS_FLAG:
logger.info("Playback: Download in progress, checking if file is now ready")
try:
debrid_service = get_download_service(config)
debrid_service = get_download_service(config, debrid_session)
if debrid_service:
try:
direct_link = debrid_service.get_stream_link(query, config, ip)
direct_link = await debrid_service.get_stream_link(query, config, ip)
if direct_link and direct_link != settings.no_cache_video_url:
logger.success("Playback: File is now ready! Clearing download flag and returning direct link")
await redis_cache.delete(cache_key)
ready_cache_key = f"ready:{api_key}:{json.dumps(query)}_{ip}"
await redis_cache.set(ready_cache_key, "READY", expiration=300)
await redis_cache.set(ready_cache_key, "READY", expiration=300)
direct_link_cache_key = f"direct_link:{api_key}:{json.dumps(query)}_{ip}"
await redis_cache.set(direct_link_cache_key, direct_link, expiration=600)
await redis_cache.set(direct_link_cache_key, direct_link, expiration=600)
return direct_link
except DebridError as debrid_err:
logger.warning(f"Playback: Debrid error in progress check {debrid_err.status_keys}, returning status video")
await redis_cache.delete(cache_key)
error_url = get_status_video_url(debrid_err.status_keys, default_key="UNKNOWN")
return RedirectResponse(url=error_url, status_code=302)
except Exception as link_error:
logger.debug(f"Playback: File not ready yet: {str(link_error)}")
except Exception as e:
logger.warning(f"Playback: Error checking download status: {str(e)}")
if config.get("stremthru") and query.get("service") in ["ST", "RD", "AD", "PM", "TB", "OC", "DL", "ED", "PK"]:
try:
cached_link = await redis_cache.get(stremthru_link_key)
if cached_link:
logger.info(f"Playback: Utilisation d'un lien de streaming StremThru mis en cache")
return cached_link
from stream_fusion.utils.debrid.stremthru import StremThru
stremthru_service = get_download_service(config)
if not isinstance(stremthru_service, StremThru):
logger.warning(f"Playback: Le service de téléchargement n'est pas StremThru, c'est {type(stremthru_service).__name__}")
return settings.no_cache_video_url
magnet = query.get("magnet")
if magnet:
logger.info(f"Playback: Génération directe d'un lien de streaming via StremThru")
stream_link = stremthru_service.get_stream_link(query, config, ip)
if stream_link:
logger.success(f"Playback: Lien de streaming généré avec succès via StremThru")
await redis_cache.set(stremthru_link_key, stream_link, expiration=30)
return stream_link
else:
logger.info(f"Playback: Échec de génération du lien via StremThru, utilisation de no_cache_video_url")
except Exception as e:
logger.warning(f"Playback: Erreur lors de la vérification de la disponibilité du magnet sur StremThru: {str(e)}")
return settings.no_cache_video_url
# Mark the start of the download
@ -201,14 +193,14 @@ async def handle_download(
)
try:
debrid_service = get_download_service(config)
debrid_service = get_download_service(config, debrid_session)
if not debrid_service:
raise HTTPException(
status_code=500, detail="Download service not available"
)
if isinstance(debrid_service, RealDebrid):
torrent_id = debrid_service.add_magnet_or_torrent_and_select(query, ip)
torrent_id = await debrid_service.add_magnet_or_torrent_and_select(query, ip)
logger.success(
f"Playback: Added magnet or torrent to Real-Debrid: {torrent_id}"
)
@ -225,7 +217,7 @@ async def handle_download(
else None
)
privacy = query.get("privacy", "private")
torrent_info = debrid_service.add_magnet_or_torrent(
torrent_info = await debrid_service.add_magnet_or_torrent(
magnet, torrent_download, ip, privacy
)
logger.success(
@ -244,7 +236,7 @@ async def handle_download(
else None
)
try:
if debrid_service.start_background_caching(magnet, query):
if await debrid_service.start_background_caching(magnet, query):
logger.success(
f"Playback: Started background caching for magnet: {magnet[:50]}"
)
@ -253,6 +245,8 @@ async def handle_download(
status_code=500,
detail="Failed to start background caching"
)
except DebridError:
raise
except Exception as e:
logger.error(f"Error starting background caching: {str(e)}")
raise HTTPException(
@ -263,11 +257,19 @@ async def handle_download(
except Exception as e:
await redis_cache.delete(cache_key)
logger.error(f"Playback: Error handling download: {str(e)}", exc_info=True)
# Check if torrent is banned (451 - Unavailable For Legal Reasons)
if "451" in str(e):
logger.warning(f"Playback: Torrent banned (451), returning banned video")
return settings.banned_video_url
if isinstance(e, DebridError):
logger.warning(f"Playback: Debrid error {e.status_keys}, returning status video")
error_url = get_status_video_url(e.status_keys, default_key="UNKNOWN")
return RedirectResponse(url=error_url, status_code=302)
raise e
async def get_stream_link(
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache, cache_user_identifier: str, stream_id: str = None
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache, cache_user_identifier: str, stream_id: str = None, debrid_session=None
) -> str:
logger.debug(f"Playback: Getting stream link for query: {decoded_query}, IP: {ip}")
@ -302,21 +304,21 @@ async def get_stream_link(
logger.info(f"Playback: Stream link found in cache: {cached_link}")
return cached_link
debrid_service = get_download_service(config)
debrid_service = get_download_service(config, debrid_session)
if not debrid_service:
logger.error("Playback: No debrid service available")
raise HTTPException(status_code=500, detail="No debrid service available")
if service:
logger.debug(f"Playback: Getting stream link from {service}")
link = debrid_service.get_stream_link(query, config, ip)
link = await debrid_service.get_stream_link(query, config, ip)
if link is None:
logger.error("Playback: Debrid service returned None instead of a valid link")
logger.error(f"Playback: Query: {decoded_query}")
logger.error(f"Playback: Service: {service}")
raise HTTPException(status_code=500, detail="Debrid service failed to provide a valid stream link")
logger.warning("Playback: Debrid service returned None instead of a valid link")
logger.warning(f"Playback: Query: {decoded_query}")
logger.warning(f"Playback: Service: {service}")
raise DebridError("Debrid service failed to provide a valid stream link", error_code="UNKNOWN")
else:
logger.error("Playback: Service not found in query")
raise HTTPException(status_code=500, detail="Service not found in query")
@ -330,8 +332,7 @@ async def get_stream_link(
return link
@router.get("/{config}/{query}", responses={500: {"model": ErrorResponse}})
@rate_limiter(limit=100, seconds=60, redis=redis_session)
@router.api_route("/{config}/{query:path}", methods=["GET", "HEAD"], responses={500: {"model": ErrorResponse}})
async def get_playback(
config: str,
query: str,
@ -339,10 +340,12 @@ async def get_playback(
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
apikey_dao: APIKeyDAO = Depends(),
):
if request.method == "HEAD":
return Response(status_code=200)
try:
config = parse_config(config)
api_key = config.get("apiKey")
ip = request.client.host
ip = get_client_ip(request)
cache_user_identifier = api_key if api_key else ip
# Only validate the API key if it exists
@ -367,69 +370,62 @@ async def get_playback(
logger.debug(f"Playback: Received playback request for query: {decoded_query}")
service = query_dict.get("service", False)
debrid_session = getattr(request.app.state, 'debrid_session', None)
if service == "DL":
# Pass cache_user_identifier to handle_download if needed, otherwise it uses ip/query_dict
link = await handle_download(query_dict, config, ip, redis_cache)
return RedirectResponse(url=link, status_code=status.HTTP_302_FOUND)
result = await handle_download(query_dict, config, ip, redis_cache, debrid_session)
if isinstance(result, Response):
return result
return RedirectResponse(url=result, status_code=status.HTTP_302_FOUND)
# Use cache_user_identifier for lock key
lock_key = f"lock:stream:{cache_user_identifier}:{decoded_query}"
lock = redis_client.lock(lock_key, timeout=60)
try:
if await lock.acquire(blocking=False):
logger.debug("Playback: Lock acquired, getting stream link")
# Extraire stream_id depuis le referer pour la cohérence avec le pre-fetch
stream_id = None
referer = request.headers.get("referer", "")
if "/stream/" in referer:
try:
# Extract stream_id from referer URL like: .../stream/series/tt10919420:3:1
stream_id = referer.split("/stream/")[1].split("/")[-1].replace(".json", "")
logger.debug(f"Playback: Extracted stream_id from referer: {stream_id}")
except:
pass
link = await get_stream_link(decoded_query, config, ip, redis_cache, cache_user_identifier, stream_id)
else:
logger.debug("Playback: Lock not acquired, waiting for cached link")
# Extract stream_id from referer (same logic as above)
stream_id = None
referer = request.headers.get("referer", "")
if "/stream/" in referer:
try:
stream_id = referer.split("/stream/")[1].split("/")[-1].replace(".json", "")
except:
pass
# Use same cache key logic as get_stream_link
if stream_id and query_dict.get("type") == "series":
cache_key = f"stream_link:{cache_user_identifier}:{stream_id}:{query_dict.get('service', '')}"
logger.info(f"Playback: Using series cache key: {cache_key}")
logger.info(f"Playback: Stream ID: {stream_id}, Service: {query_dict.get('service', '')}")
else:
cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
logger.info(f"Playback: Using fallback cache key: {cache_key}")
for _ in range(30):
await asyncio.sleep(1)
cached_link = await redis_cache.get(cache_key)
if cached_link:
logger.debug("Playback: Cached link found while waiting")
link = cached_link
break
else:
logger.warning("Playback: Timed out waiting for cached link")
raise HTTPException(
status_code=503,
detail="Service temporarily unavailable. Please try again.",
)
finally:
# Extract stream_id from referer
stream_id = None
referer = request.headers.get("referer", "")
if "/stream/" in referer:
try:
await lock.release()
logger.debug("Playback: Lock released")
except LockError:
logger.warning("Playback: Failed to release lock (already released)")
stream_id = referer.split("/stream/")[1].split("/")[-1].replace(".json", "")
except:
pass
# Fast path: check cache before acquiring lock
if stream_id and query_dict.get("type") == "series":
fast_cache_key = f"stream_link:{cache_user_identifier}:{stream_id}:{query_dict.get('service', '')}"
else:
fast_cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
fast_cached = await redis_cache.get(fast_cache_key)
if fast_cached:
logger.info("Playback: Fast path cache hit, skipping lock")
link = fast_cached
else:
# Use cache_user_identifier for lock key
lock_key = f"lock:stream:{cache_user_identifier}:{decoded_query}"
lock = redis_client.lock(lock_key, timeout=60)
try:
if await lock.acquire(blocking=False):
logger.debug("Playback: Lock acquired, getting stream link")
link = await get_stream_link(decoded_query, config, ip, redis_cache, cache_user_identifier, stream_id, debrid_session)
else:
logger.debug("Playback: Lock not acquired, waiting for cached link")
for _ in range(60):
cached_link = await redis_cache.get(fast_cache_key)
if cached_link:
logger.debug("Playback: Cached link found while waiting")
link = cached_link
break
await asyncio.sleep(0.5)
else:
logger.warning("Playback: Timed out waiting for cached link")
raise HTTPException(
status_code=503,
detail="Service temporarily unavailable. Please try again.",
)
finally:
try:
await lock.release()
logger.debug("Playback: Lock released")
except LockError:
logger.warning("Playback: Failed to release lock (already released)")
# Vérifier si la proxification est activée pour cette clé API
use_proxy = settings.proxied_link # Valeur par défaut
@ -447,7 +443,7 @@ async def get_playback(
if not use_proxy:
logger.debug(f"Playback: Redirecting to non-proxied link: {link}")
return RedirectResponse(
url=link, status_code=status.HTTP_301_MOVED_PERMANENTLY
url=link, status_code=status.HTTP_302_FOUND
)
if service == "TB": # TODO: Check this for torbox
@ -502,6 +498,10 @@ async def get_playback(
background=BackgroundTask(streamer.close),
)
except DebridError as e:
logger.warning(f"Playback: Debrid error {e.status_keys}, returning status video")
error_url = get_status_video_url(e.status_keys, default_key="UNKNOWN")
return RedirectResponse(url=error_url, status_code=302)
except Exception as e:
logger.error(f"Playback: Playback error: {str(e)}", exc_info=True)
raise HTTPException(
@ -512,98 +512,5 @@ async def get_playback(
)
@router.head(
"/{config}/{query}",
response_model=HeadResponse,
responses={500: {"model": ErrorResponse}, 202: {"model": None}},
)
async def head_playback(
config: str,
query: str,
request: Request,
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
apikey_dao: APIKeyDAO = Depends(),
):
try:
config = parse_config(config)
api_key = config.get("apiKey")
ip = request.client.host
cache_user_identifier = api_key if api_key else ip
# Only validate the API key if it exists
if api_key:
try:
await check_api_key(api_key, apikey_dao)
logger.info(f"Playback HEAD: Valid API key provided by {ip}")
except HTTPException as e:
logger.warning(f"Playback HEAD: Invalid API key provided by {ip}. Error: {e.detail}")
raise e # Re-raise if validation fails for a provided key
else:
# Don't raise 401, just log if no key is provided
logger.info(f"Playback HEAD: No API key provided by {ip}. Proceeding without API key validation.")
if not query:
raise HTTPException(status_code=400, detail="Query required.")
decoded_query = decodeb64(query)
query_dict = json.loads(decoded_query)
service = query_dict.get("service", False)
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",
}
if service == "DL":
# Check if download is in progress using cache_user_identifier
download_cache_key = f"download:{cache_user_identifier}:{json.dumps(query_dict)}"
if await redis_cache.get(download_cache_key) == DOWNLOAD_IN_PROGRESS_FLAG:
logger.info("Playback: Download in progress, returning 202 Accepted")
return Response(status_code=status.HTTP_202_ACCEPTED, headers=headers)
else:
logger.info("Playback: Download not started, returning 200 OK")
return Response(status_code=status.HTTP_200_OK, headers=headers)
# Use cache_user_identifier for stream link cache key
cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
for _ in range(30):
if await redis_cache.exists(cache_key):
link = await redis_cache.get(cache_key)
if (
not settings.proxied_link
): # avoid sending HEAD request if link is sent directly
return Response(status_code=status.HTTP_200_OK, headers=headers)
async with request.app.state.http_session.head(link) as response:
if response.status == 200:
headers["Content-Length"] = response.headers.get(
"Content-Length", "0"
)
return Response(status_code=status.HTTP_200_OK, headers=headers)
await asyncio.sleep(1)
return Response(status_code=status.HTTP_202_ACCEPTED, headers=headers)
except redis.ConnectionError as e:
logger.error(f"Playback: Redis connection error: {e}")
return Response(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content="Service temporarily unavailable",
)
except Exception as e:
logger.error(f"Playback: HEAD request error: {e}")
return Response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=ErrorResponse(
detail="An error occurred while processing the request."
).model_dump_json(),
media_type="application/json",
)
# HEAD handler removed - FastAPI automatically handles HEAD requests
# by routing them to the GET handler (same behavior as Comet)

View file

@ -21,7 +21,7 @@ class Video(BaseModel):
class Meta(BaseModel):
id: str # = Field(alias="_id")
name: str = Field(alias="title")
name: str
type: str = Field(default="movie")
poster: str | None = None
background: str | None = None

View file

@ -1,17 +1,16 @@
import asyncio
import pickle
from datetime import datetime, timedelta
from redis import Redis
from tmdbv3api import TMDb, Movie, TV, Season, Discover, Find
from fastapi_simple_rate_limiter import rate_limiter
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi_simple_rate_limiter.database import create_redis_session
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
from stream_fusion.settings import settings
from stream_fusion.utils.parse_config import parse_config
from stream_fusion.utils.security.security_api_key import check_api_key
from stream_fusion.utils.yggfilx.yggflix_api import YggflixAPI
from stream_fusion.web.root.catalog.schemas import (
ErrorResponse,
MetaItem,
@ -24,8 +23,6 @@ from stream_fusion.logging_config import logger
router = APIRouter()
redis_session = create_redis_session(host=settings.redis_host, port=settings.redis_port, db=settings.redis_db)
tmdb = TMDb()
tmdb.api_key = settings.tmdb_api_key
tmdb.language = "fr-FR"
@ -77,10 +74,17 @@ def extract_year(date_string):
return None
async def create_meta_object(details, item_type: str, imdb_id: str):
async def create_meta_object(details, item_type: str, imdb_id: str, include_episodes: bool = True):
"""
Crée un objet Meta à partir des détails TMDB.
Args:
include_episodes: Si False, ne charge pas les épisodes (pour le catalogue).
Si True, charge tous les épisodes (pour le endpoint /meta).
"""
meta = Meta(
id=imdb_id,
title=getattr(details, "title", None) or getattr(details, "name", None),
name=getattr(details, "title", None) or getattr(details, "name", None),
type=item_type,
poster=(
f"https://image.tmdb.org/t/p/w500{details.poster_path}"
@ -115,7 +119,8 @@ async def create_meta_object(details, item_type: str, imdb_id: str):
meta.stream = {
"id": imdb_id
}
elif item_type == "series" and hasattr(details, "seasons"):
elif item_type == "series" and include_episodes and hasattr(details, "seasons"):
# Charger les épisodes seulement si demandé (pour /meta, pas pour /catalog)
meta.videos = []
for season in details.seasons:
season_details = await get_tv_season_details(
@ -150,7 +155,6 @@ async def get_tmdb_id_from_imdb(imdb_id: str) -> str:
@router.get(
"/{config}/catalog/{type}/{id}/skip={skip}.json", responses={500: {"model": ErrorResponse}}
)
@rate_limiter(limit=20, seconds=60, redis=redis_session)
async def get_catalog(
config: str,
type: str,
@ -158,7 +162,8 @@ async def get_catalog(
request: Request,
skip: int = 0,
redis_client: Redis = Depends(get_redis),
apikey_dao: APIKeyDAO = Depends()
apikey_dao: APIKeyDAO = Depends(),
torrentitem_dao: TorrentItemDAO = Depends()
):
try:
config_data = parse_config(config)
@ -173,12 +178,8 @@ async def get_catalog(
if type not in {"movie", "series"} or id not in {
"latest_movies",
"recently_added_movies",
"popular_movies",
"top_rated_movies",
"latest_tv_shows",
"recently_added_tv_shows",
"popular_tv_shows",
"top_rated_tv_shows",
}:
raise HTTPException(status_code=400, detail="Invalid type or catalog id")
@ -192,48 +193,105 @@ async def get_catalog(
logger.info(f"Catalog not found in cache for key: {cache_key}. Generating...")
item_ids = []
use_yggflix = config_data.get("yggflix", False)
yggflix_ids = {
"latest_movies": "latest_movies",
"recently_added_movies": "recently_added_movies",
"latest_tv_shows": "latest_tv_shows",
"recently_added_tv_shows": "recently_added_tv_shows",
episode_info_map = {} # Mapping tmdb_id -> episode info pour les séries
# Map catalog IDs to item types for PostgreSQL queries
catalog_type_map = {
"latest_movies": "movie",
"recently_added_movies": "movie",
"latest_tv_shows": "series",
"recently_added_tv_shows": "series",
}
if use_yggflix and id in yggflix_ids:
item_type = catalog_type_map.get(id)
# For "latest_*": Different logic for movies vs series
if id.startswith("latest_"):
try:
logger.info(f"Attempting to fetch catalog from Yggflix for id: {yggflix_ids[id]}")
yggflix = YggflixAPI()
home_data = await asyncio.to_thread(yggflix.get_home)
item_ids = [item["id"] for item in home_data.get(yggflix_ids[id], []) if "id" in item]
logger.info(f"Fetched {len(item_ids)} IDs from Yggflix for {yggflix_ids[id]}")
except Exception as ygg_error:
logger.warning(f"Failed to fetch catalog from Yggflix for id {yggflix_ids[id]}: {ygg_error}. Falling back to TMDb.")
if item_type == "movie":
# Films: TMDB discover (films récents des 6 derniers mois) + filtre FR disponible en PostgreSQL
logger.info(f"Fetching latest movies from TMDB discover (last 6 months) and filtering by PostgreSQL availability")
today = datetime.now().strftime('%Y-%m-%d')
six_months_ago = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d')
tmdb_results = await asyncio.gather(
*[asyncio.to_thread(discover.discover_movies, {
'sort_by': 'popularity.desc',
'primary_release_date.gte': six_months_ago,
'primary_release_date.lte': today,
'page': page,
}) for page in range(1, 11)]
)
all_tmdb_ids = []
for results in tmdb_results:
for item in results:
if hasattr(item, 'id'):
all_tmdb_ids.append(item.id)
logger.info(f"Fetched {len(all_tmdb_ids)} TMDB IDs from TMDB discover for {id}")
# Filtre par disponibilité FR/MULTI, trié par date d'ajout en base (plus récent en premier)
item_ids = await torrentitem_dao.filter_existing_tmdb_ids(all_tmdb_ids, item_type, sort_by_added=True)
logger.info(f"Filtered to {len(item_ids)} available TMDB IDs (FR/MULTI, sorted by added date) for {id}")
else:
# Séries: TMDB discover avec air_date récent (7 derniers jours) + filtre FR en PostgreSQL
# Utilise discover au lieu de on_the_air car on_the_air vire trop vite les séries binge-release
logger.info(f"Fetching latest series from TMDB discover (air_date last 7 days) and filtering by PostgreSQL availability")
today = datetime.now().strftime('%Y-%m-%d')
week_ago = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
tmdb_results = await asyncio.gather(
*[asyncio.to_thread(discover.discover_tv_shows, {
'air_date.gte': week_ago,
'without_genres': '10763,10764,10766,10767',
'air_date.lte': today,
'sort_by': 'popularity.desc',
'page': page
}) for page in range(1, 11)]
)
all_tmdb_ids = []
for results in tmdb_results:
for item in results:
if hasattr(item, 'id'):
all_tmdb_ids.append(item.id)
logger.info(f"Fetched {len(all_tmdb_ids)} series TMDB IDs from TMDB discover (air_date) for {id}")
# Filtre par disponibilité FR/MULTI, trié par dernier nouvel épisode en base
# Récupère aussi les infos d'épisode pour l'afficher dans le titre
episode_data = await torrentitem_dao.filter_existing_tmdb_ids(all_tmdb_ids, item_type, sort_by_added=True, return_episode_info=True)
item_ids = [ep['tmdb_id'] for ep in episode_data]
# Créer un mapping tmdb_id -> episode info
episode_info_map = {ep['tmdb_id']: ep for ep in episode_data}
logger.info(f"Filtered to {len(item_ids)} available series TMDB IDs (FR/MULTI, sorted by new episode date) for {id}")
except Exception as e:
logger.warning(f"Failed to fetch latest catalog for {id}: {e}. Falling back to PostgreSQL only.")
item_ids = await torrentitem_dao.get_latest_tmdb_ids(item_type, limit=50)
# For "recently_added_*": Use PostgreSQL directly (recent uploads)
elif id.startswith("recently_added_"):
try:
logger.info(f"Fetching recently added TMDB IDs from PostgreSQL for {item_type}")
item_ids = await torrentitem_dao.get_recently_added_tmdb_ids(item_type, limit=50)
logger.info(f"Fetched {len(item_ids)} TMDB IDs from PostgreSQL for {id}")
except Exception as pg_error:
logger.warning(f"Failed to fetch catalog from PostgreSQL for {id}: {pg_error}")
item_ids = []
# Fallback to TMDb discover if no results
if not item_ids:
logger.info(f"Fetching catalog from TMDb for type: {type}, id: {id}")
logger.info(f"Fallback: Fetching catalog from TMDb discover for type: {type}, id: {id}")
try:
tmdb_params = {"page": 1}
if type == "movie":
if id == "popular_movies": discover_func = discover.discover_movies
elif id == "top_rated_movies": discover_func = discover.discover_movies; tmdb_params['sort_by'] = 'vote_average.desc'
else: discover_func = discover.discover_movies
results = await asyncio.to_thread(discover_func, tmdb_params)
discover_func = discover.discover_movies
else:
if id == "popular_tv_shows": discover_func = discover.discover_tv_shows
elif id == "top_rated_tv_shows": discover_func = discover.discover_tv_shows; tmdb_params['sort_by'] = 'vote_average.desc'
else: discover_func = discover.discover_tv_shows
results = await asyncio.to_thread(discover_func, tmdb_params)
discover_func = discover.discover_tv_shows
results = await asyncio.to_thread(discover_func, tmdb_params)
item_ids = [item.id for item in results if hasattr(item, 'id')]
logger.info(f"Fetched {len(item_ids)} IDs from TMDb for {type}/{id}")
logger.info(f"Fetched {len(item_ids)} IDs from TMDb discover for {type}/{id}")
except Exception as tmdb_error:
logger.error(f"Failed to fetch catalog from TMDb for {type}/{id}: {tmdb_error}", exc_info=True)
item_ids = []
logger.error(f"Failed to fetch catalog from TMDb for {type}/{id}: {tmdb_error}", exc_info=True)
item_ids = []
metas = []
pipeline = redis_client.pipeline()
@ -245,7 +303,19 @@ async def get_catalog(
if cached_item:
try:
metas.append(Meta.model_validate(cached_item))
meta = Meta.model_validate(cached_item)
# Ajouter l'info d'épisode au DÉBUT du titre pour les séries (sans modifier le cache)
if tmdb_id in episode_info_map:
ep_info = episode_info_map[tmdb_id]
season = ep_info.get('season', '').strip('[]') or ''
episode = ep_info.get('episode', '').strip('[]') or ''
if season:
if episode:
ep_prefix = f"S{season.zfill(2)}E{episode.zfill(2)}"
else:
ep_prefix = f"S{season.zfill(2)}"
meta.name = f"{ep_prefix} - {meta.name}"
metas.append(meta)
except Exception as validation_error:
logger.warning(f"Failed to validate cached meta for TMDB ID {tmdb_id}: {validation_error}")
continue
@ -258,18 +328,25 @@ async def get_catalog(
else:
details = await get_tv_details(tmdb_id)
item_type = "series"
external_ids = await asyncio.to_thread(tv.external_ids, tmdb_id)
imdb_id = external_ids.get("imdb_id")
# Vérifier le cache tmdbid_to_imdbid avant d'appeler external_ids
cached_imdb_id = await asyncio.to_thread(redis_client.get, f"tmdbid_to_imdbid:{tmdb_id}")
if cached_imdb_id:
imdb_id = cached_imdb_id.decode('utf-8') if isinstance(cached_imdb_id, bytes) else cached_imdb_id
logger.debug(f"IMDb ID found in cache for TMDB ID {tmdb_id}: {imdb_id}")
else:
external_ids = await asyncio.to_thread(tv.external_ids, tmdb_id)
imdb_id = external_ids.get("imdb_id")
if not imdb_id:
logger.warning(f"No IMDb ID found for TMDB ID: {tmdb_id}")
continue
meta = await create_meta_object(details, item_type, imdb_id)
metas.append(meta)
# include_episodes=False pour le catalogue (pas besoin des épisodes)
meta = await create_meta_object(details, item_type, imdb_id, include_episodes=False)
item_cache_key_imdb = f"imdbid_item:{imdb_id}"
try:
# Cacher la version sans l'épisode dans le titre
pipeline.set(
item_cache_key_tmdb, pickle.dumps(meta), ex=7 * 24 * 60 * 60
)
@ -282,6 +359,20 @@ async def get_catalog(
except Exception as cache_err:
logger.error(f"Error adding item TMDB:{tmdb_id}/IMDB:{imdb_id} to cache pipeline: {cache_err}")
# Ajouter l'info d'épisode au DÉBUT du titre pour les séries (après le cache)
if tmdb_id in episode_info_map:
ep_info = episode_info_map[tmdb_id]
season = ep_info.get('season', '').strip('[]') or ''
episode = ep_info.get('episode', '').strip('[]') or ''
if season:
if episode:
ep_prefix = f"S{season.zfill(2)}E{episode.zfill(2)}"
else:
ep_prefix = f"S{season.zfill(2)}"
meta.name = f"{ep_prefix} - {meta.name}"
metas.append(meta)
except Exception as e:
logger.error(f"Error processing item with TMDB ID {tmdb_id}: {str(e)}")
continue
@ -310,7 +401,6 @@ async def get_catalog(
@router.get(
"/{config}/meta/{type}/{id}.json", responses={500: {"model": ErrorResponse}}
)
@rate_limiter(limit=20, seconds=60, redis=redis_session)
async def get_meta(
config: str,
type: str,

View file

@ -35,6 +35,8 @@ async def configure(request: Request):
"ygg_unique_account": settings.ygg_unique_account,
"jackett_enable": settings.jackett_enable,
"tb_unique_account": settings.tb_unique_account,
"c411_unique_account": settings.c411_unique_account,
"torr9_unique_account": settings.torr9_unique_account,
})

View file

@ -1,221 +0,0 @@
import json
import queue
import re
import threading
from typing import List
from RTN import ParsedData
from stream_fusion.constants import FR_RELEASE_GROUPS, FRENCH_PATTERNS
from stream_fusion.utils.models.media import Media
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.utils.string_encoding import encodeb64
INSTANTLY_AVAILABLE = ""
DOWNLOAD_REQUIRED = "⬇️"
DIRECT_TORRENT = "🏴‍☠️"
def get_emoji(language):
emoji_dict = {
"fr": "🇫🇷 FRENCH",
"en": "🇬🇧 ENGLISH",
"es": "🇪🇸 SPANISH",
"de": "🇩🇪 GERMAN",
"it": "🇮🇹 ITALIAN",
"pt": "🇵🇹 PORTUGUESE",
"ru": "🇷🇺 RUSSIAN",
"in": "🇮🇳 INDIAN",
"nl": "🇳🇱 DUTCH",
"hu": "🇭🇺 HUNGARIAN",
"la": "🇲🇽 LATINO",
"multi": "🌍 MULTi",
}
return emoji_dict.get(language, "🇬🇧")
def filter_by_availability(item):
if item["name"].startswith(INSTANTLY_AVAILABLE):
return 0
else:
return 1
def filter_by_direct_torrnet(item):
if item["name"].startswith(DIRECT_TORRENT):
return 1
else:
return 0
def extract_release_group(title):
combined_pattern = "|".join(FR_RELEASE_GROUPS)
match = re.search(combined_pattern, title)
return match.group(0) if match else None
def detect_french_language(title):
for language, pattern in FRENCH_PATTERNS.items():
match = re.search(pattern, title, re.IGNORECASE)
if match:
return language
return None
def _generate_binge_group(torrent_item: TorrentItem, media: Media) -> str:
"""Génère un bingeGroup intelligent selon le type de média"""
if media.type == "movie":
return f"stremio-jackett-{torrent_item.info_hash}"
if media.type == "series":
series_id = media.id.split(":")[0] if ":" in media.id else media.id
resolution = torrent_item.parsed_data.resolution if torrent_item.parsed_data.resolution else "Unknown"
team = extract_release_group(torrent_item.raw_title) or torrent_item.parsed_data.group
if team:
return f"stremio-jackett-{series_id}-{resolution}-{team}"
else:
return f"stremio-jackett-{series_id}-{resolution}"
return f"stremio-jackett-{torrent_item.info_hash}"
def parse_to_debrid_stream(
torrent_item: TorrentItem,
configb64,
host,
torrenting,
results: queue.Queue,
media: Media,
):
if torrent_item.availability:
name = f"{INSTANTLY_AVAILABLE}|{torrent_item.availability}-|{INSTANTLY_AVAILABLE}"
else:
name = f"{DOWNLOAD_REQUIRED}|DL-|{DOWNLOAD_REQUIRED}"
parsed_data: ParsedData = torrent_item.parsed_data
resolution = parsed_data.resolution if parsed_data.resolution else "Unknow"
name += f"\n |_{resolution}_|"
size_in_gb = round(int(torrent_item.size) / 1024 / 1024 / 1024, 2)
title = f"{torrent_item.raw_title}\n"
if media.type == "series" and torrent_item.file_name is not None:
title += f"{torrent_item.file_name}\n"
if torrent_item.languages:
title += "/".join(get_emoji(language) for language in torrent_item.languages)
else:
title += "🌐"
groupe = extract_release_group(torrent_item.raw_title)
lang_type = detect_french_language(torrent_item.raw_title)
if lang_type:
title += f"{lang_type} "
if groupe:
title += f" ☠️ {groupe}"
elif parsed_data.group:
title += f" ☠️ {parsed_data.group}"
title += "\n"
title += (
f"👥 {torrent_item.seeders} 💾 {size_in_gb}GB 🔍 {torrent_item.indexer}\n"
)
if parsed_data.codec:
title += f"🎥 {parsed_data.codec} "
if parsed_data.quality:
title += f"📺 {parsed_data.quality} "
if parsed_data.audio:
title += f"🎧 {' '.join(parsed_data.audio)}"
if parsed_data.codec or parsed_data.audio or parsed_data.resolution:
title += "\n"
queryb64 = encodeb64(
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": _generate_binge_group(torrent_item, media),
"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"
if (
len(parsed_data.quality) > 0
and parsed_data.quality[0] != "Unknown"
and parsed_data.quality[0] != ""
):
name += f"({'|'.join(parsed_data.quality)})"
results.put(
{
"name": name,
"description": title,
"infoHash": torrent_item.info_hash,
"fileIdx": (
int(torrent_item.file_index) if torrent_item.file_index else None
),
"behaviorHints": {
"bingeGroup": _generate_binge_group(torrent_item, media),
"filename": (
torrent_item.file_name
if torrent_item.file_name is not None
else torrent_item.raw_title
),
},
# "sources": ["tracker:" + tracker for tracker in torrent_item.trackers]
}
)
def parse_to_stremio_streams(torrent_items: List[TorrentItem], config, media):
stream_list = []
threads = []
thread_results_queue = queue.Queue()
configb64 = encodeb64(json.dumps(config).replace("=", "%3D"))
for torrent_item in torrent_items[: int(config["maxResults"])]:
thread = threading.Thread(
target=parse_to_debrid_stream,
args=(
torrent_item,
configb64,
config["addonHost"],
config["torrenting"],
thread_results_queue,
media,
),
daemon=True,
)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
while not thread_results_queue.empty():
stream_list.append(thread_results_queue.get())
if len(stream_list) == 0:
return []
if config["debrid"]:
stream_list = sorted(stream_list, key=filter_by_availability)
stream_list = sorted(stream_list, key=filter_by_direct_torrnet)
return stream_list

View file

@ -25,6 +25,7 @@ from stream_fusion.utils.jackett.jackett_service import JackettService
from stream_fusion.utils.parser.parser_service import StreamParser
from stream_fusion.utils.sharewood.sharewood_service import SharewoodService
from stream_fusion.utils.yggfilx.yggflix_service import YggflixService
from stream_fusion.utils.yggfilx.yggflix_result import YggflixResult
from stream_fusion.utils.metdata.cinemeta import Cinemeta
from stream_fusion.utils.metdata.tmdb import TMDB
from stream_fusion.utils.models.movie import Movie
@ -33,29 +34,41 @@ from stream_fusion.utils.parse_config import parse_config
from stream_fusion.utils.security.security_api_key import check_api_key
from stream_fusion.utils.torrent.torrent_item import TorrentItem
from stream_fusion.web.root.search.schemas import SearchResponse, Stream
from stream_fusion.web.root.search.stremio_parser import parse_to_stremio_streams
from stream_fusion.utils.torrent.torrent_service import TorrentService
from stream_fusion.utils.torrent.torrent_smart_container import TorrentSmartContainer
from stream_fusion.utils.zilean.zilean_result import ZileanResult
from stream_fusion.utils.zilean.zilean_service import ZileanService
from stream_fusion.utils.c411.c411_service import C411Service
from stream_fusion.utils.c411.c411_result import C411Result as C411SearchResult
from stream_fusion.utils.torr9.torr9_service import Torr9Service
from stream_fusion.utils.torr9.torr9_result import Torr9Result as Torr9SearchResult
from stream_fusion.settings import settings
router = APIRouter()
def get_client_ip(request: Request) -> str:
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
return request.client.host
async def full_prefetch_from_cache(media, config, redis_cache, stream_cache_key, get_metadata, stream_type, debrid_services, torrent_dao, request):
"""Pre-fetch complet de l'épisode suivant en arrière-plan"""
try:
# Petit délai pour ne pas surcharger immédiatement après la recherche principale
await asyncio.sleep(1.0)
http_session = getattr(request.app.state, 'http_session', None)
current_season_num = int(media.season.replace("S", ""))
current_episode_num = int(media.episode.replace("E", ""))
next_episode_num = current_episode_num + 1
next_episode_id = f"{media.id.split(':')[0]}:{current_season_num}:{next_episode_num}"
next_media_mock = type(media)(
id=next_episode_id,
tmdb_id=media.tmdb_id,
@ -64,29 +77,45 @@ async def full_prefetch_from_cache(media, config, redis_cache, stream_cache_key,
episode=f"E{next_episode_num:02d}",
languages=media.languages
)
next_stream_key = stream_cache_key(next_media_mock)
cached_next = await redis_cache.get(next_stream_key)
if cached_next is None:
logger.debug(f"Pre-fetch: Starting full background search for next episode {next_episode_id}")
async def fetch_next_metadata():
return await get_metadata(next_episode_id, stream_type)
next_media = await asyncio.wait_for(
redis_cache.get_or_set(lambda: get_metadata(next_episode_id, stream_type), next_episode_id, stream_type, config["metadataProvider"]),
redis_cache.get_or_set(fetch_next_metadata, next_episode_id, stream_type, config["metadataProvider"]),
timeout=5.0
)
search_results = []
postgres_results = []
background_session = request.app.state.db_session_factory()
try:
background_torrent_dao = TorrentItemDAO(background_session)
torrent_service = TorrentService(config, background_torrent_dao)
# Zilean
if hasattr(next_media, 'tmdb_id') and next_media.tmdb_id:
try:
postgres_items = await background_torrent_dao.search_by_tmdb_id(int(next_media.tmdb_id))
if postgres_items:
logger.debug(f"Pre-fetch: Found {len(postgres_items)} results from Postgres for TMDB ID {next_media.tmdb_id}")
for db_item in postgres_items:
if db_item.indexer in ['Yggtorrent - API', 'C411 - API', 'Torr9 - API']:
postgres_results.append(db_item.to_torrent_item())
postgres_results = filter_items(postgres_results, next_media, config=config)
logger.debug(f"Pre-fetch: After filtering: {len(postgres_results)} Postgres results for {next_media.season}{next_media.episode}")
except Exception as pg_error:
logger.debug(f"Pre-fetch: Postgres search failed: {str(pg_error)}")
if config["zilean"]:
zilean_service = ZileanService(config)
zilean_search_results = zilean_service.search(next_media)
zilean_service = ZileanService(config, session=http_session)
zilean_search_results = await zilean_service.search(next_media)
if zilean_search_results:
zilean_search_results = [
ZileanResult().from_api_cached_item(torrent, next_media)
@ -96,41 +125,67 @@ async def full_prefetch_from_cache(media, config, redis_cache, stream_cache_key,
zilean_search_results = filter_items(zilean_search_results, next_media, config=config)
zilean_search_results = await torrent_service.convert_and_process(zilean_search_results)
search_results = merge_items(search_results, zilean_search_results)
# YggFlix
if config["yggflix"] and len(search_results) < int(config["minCachedResults"]):
yggflix_service = YggflixService(config)
yggflix_search_results = yggflix_service.search(next_media)
if yggflix_search_results:
yggflix_search_results = filter_items(yggflix_search_results, next_media, config=config)
yggflix_search_results = await torrent_service.convert_and_process(yggflix_search_results)
search_results = merge_items(search_results, yggflix_search_results)
if config.get("c411"):
try:
c411_service = C411Service(config, session=http_session)
c411_results = await c411_service.search(next_media)
if c411_results:
c411_results = [
C411SearchResult().from_api_item(item, next_media)
for item in c411_results
if getattr(item, "info_hash", None) and len(item.info_hash) == 40
]
c411_results = await torrent_service.convert_and_process(c411_results)
search_results = merge_items(search_results, c411_results)
logger.debug(f"Pre-fetch: C411 API: {len(c411_results)} results")
except Exception as e:
logger.debug(f"Pre-fetch: C411 search failed: {e}")
if config.get("torr9"):
try:
torr9_service = Torr9Service(config, session=http_session)
torr9_results = await torr9_service.search(next_media)
if torr9_results:
torr9_results = [
Torr9SearchResult().from_api_item(item, next_media)
for item in torr9_results
if getattr(item, "info_hash", None) and len(item.info_hash) == 40
]
torr9_results = await torrent_service.convert_and_process(torr9_results)
search_results = merge_items(search_results, torr9_results)
logger.debug(f"Pre-fetch: Torr9 API: {len(torr9_results)} results")
except Exception as e:
logger.debug(f"Pre-fetch: Torr9 search failed: {e}")
if postgres_results:
search_results = merge_items(postgres_results, search_results)
logger.debug(f"Pre-fetch: Merged {len(postgres_results)} Postgres + {len(search_results)} external = {len(search_results)} total")
if search_results:
# Traitement des résultats
# Sort by indexer priority (C411/Torr9=1 before Yggtorrent=2)
# so ResultsPerQualityFilter keeps complete packs first
search_results = filter_items(search_results, next_media, config=config)
filtered_results = ResultsPerQualityFilter(config).filter(search_results)
torrent_smart_container = TorrentSmartContainer(filtered_results, next_media)
# Vérifier la disponibilité
for debrid in debrid_services:
hashes = torrent_smart_container.get_unaviable_hashes()
ip = request.client.host
result = debrid.get_availability_bulk(hashes, ip)
ip = get_client_ip(request)
result = await debrid.get_availability_bulk(hashes, ip)
if result:
torrent_smart_container.update_availability(result, type(debrid), next_media)
# Cache et génération des streams
if config["cache"]:
torrent_smart_container.cache_container_items()
best_matching_results = torrent_smart_container.get_best_matching()
best_matching_results = sort_items(best_matching_results, config)
parser = StreamParser(config)
stream_list = parser.parse_to_stremio_streams(best_matching_results, next_media)
stream_list = await parser.parse_to_stremio_streams(best_matching_results, next_media)
next_stream_objects = [Stream(**stream) for stream in stream_list]
# Mettre en cache les résultats
await redis_cache.set(stream_cache_key(next_media), next_stream_objects, expiration=1200)
logger.success(f"Pre-fetch: Successfully background pre-cached {len(next_stream_objects)} streams for episode {next_episode_id}")
else:
@ -148,7 +203,6 @@ async def full_prefetch_from_cache(media, config, redis_cache, stream_cache_key,
async def simple_prefetch_next_episode(media, config, redis_cache, stream_cache_key, get_metadata, stream_type):
"""Pre-fetch simple de l'épisode suivant en arrière-plan"""
try:
await asyncio.sleep(0.5)
@ -172,9 +226,12 @@ async def simple_prefetch_next_episode(media, config, redis_cache, stream_cache_
if cached_next is None:
logger.debug(f"Pre-fetch: Starting simple background search for next episode {next_episode_id}")
async def fetch_next_metadata():
return await get_metadata(next_episode_id, stream_type)
await asyncio.wait_for(
redis_cache.get_or_set(lambda: get_metadata(next_episode_id, stream_type), next_episode_id, stream_type, config["metadataProvider"]),
redis_cache.get_or_set(fetch_next_metadata, next_episode_id, stream_type, config["metadataProvider"]),
timeout=3.0
)
logger.debug(f"Pre-fetch: Metadata cached for episode {next_episode_id}")
@ -189,7 +246,6 @@ async def simple_prefetch_next_episode(media, config, redis_cache, stream_cache_
async def prefetch_next_episode(media, config, redis_cache, stream_cache_key, get_metadata, get_and_filter_results, stream_processing, ResultsPerQualityFilter, Stream, stream_type):
"""Pre-fetch l'épisode suivant en arrière-plan"""
try:
current_season_num = int(media.season.replace("S", ""))
current_episode_num = int(media.episode.replace("E", ""))
@ -211,11 +267,14 @@ async def prefetch_next_episode(media, config, redis_cache, stream_cache_key, ge
if cached_next is None:
logger.info(f"Pre-fetch: Starting background search for next episode {next_episode_id}")
expiration_time = 1200
async def fetch_next_metadata():
return await get_metadata(next_episode_id, stream_type)
next_media = await asyncio.wait_for(
redis_cache.get_or_set(lambda: get_metadata(next_episode_id, stream_type), next_episode_id, stream_type, config["metadataProvider"]),
redis_cache.get_or_set(fetch_next_metadata, next_episode_id, stream_type, config["metadataProvider"]),
timeout=8.0
)
@ -236,7 +295,6 @@ async def prefetch_next_episode(media, config, redis_cache, stream_cache_key, ge
except asyncio.TimeoutError:
logger.debug(f"Pre-fetch: Timeout during background pre-fetch")
except Exception as e:
# Erreurs courantes qui ne nécessitent pas d'alerte
error_msg = str(e).lower()
if any(keyword in error_msg for keyword in ["connection", "timeout", "reset", "closed"]):
logger.debug(f"Pre-fetch: Network issue during background pre-fetch: {str(e)}")
@ -260,7 +318,7 @@ async def get_results(
stream_id = stream_id.replace(".json", "")
config = parse_config(config)
api_key = config.get("apiKey")
ip_address = request.client.host
ip_address = get_client_ip(request)
# Only validate the API key if it exists
if api_key:
await check_api_key(api_key, apikey_dao)
@ -268,21 +326,29 @@ async def get_results(
logger.warning("Search: API key not found in config.")
raise HTTPException(status_code=401, detail="API key not found in config.")
debrid_services = get_all_debrid_services(config)
debrid_session = getattr(request.app.state, 'debrid_session', None)
debrid_services = get_all_debrid_services(config, debrid_session)
logger.debug(f"Search: Found {len(debrid_services)} debrid services")
logger.info(
f"Search: Debrid services: {[debrid.__class__.__name__ for debrid in debrid_services]}"
)
def get_metadata(episode_id=None, media_type=None):
http_session = getattr(request.app.state, 'http_session', None)
async def get_metadata(episode_id=None, media_type=None):
logger.info(f"Search: Fetching metadata from {config['metadataProvider']}")
if config["metadataProvider"] == "tmdb" and settings.tmdb_api_key:
metadata_provider = TMDB(config)
else:
metadata_provider = Cinemeta(config)
actual_id = episode_id if episode_id is not None else stream_id
actual_type = media_type if media_type is not None else stream_type
return metadata_provider.get_metadata(actual_id, actual_type)
if config["metadataProvider"] == "tmdb" and settings.tmdb_api_key:
try:
metadata_provider = TMDB(config, session=http_session)
return await metadata_provider.get_metadata(actual_id, actual_type)
except (ValueError, IndexError, KeyError) as e:
logger.warning(f"Search: TMDB metadata fetch failed ({str(e)}), falling back to Cinemeta")
metadata_provider = Cinemeta(config, session=http_session)
return await metadata_provider.get_metadata(actual_id, actual_type)
media = await redis_cache.get_or_set(
get_metadata, stream_id, stream_type, config["metadataProvider"]
@ -306,11 +372,11 @@ async def get_results(
cached_result = await redis_cache.get(stream_cache_key(media))
if cached_result is not None:
logger.info("Search: Returning cached processed results")
if isinstance(media, Series):
asyncio.create_task(full_prefetch_from_cache(media, config, redis_cache, stream_cache_key, get_metadata, stream_type, debrid_services, torrent_dao, request))
await asyncio.sleep(0.5) # 500ms de délai pour les séries
total_time = time.time() - start
logger.success(f"Search: Request completed in {total_time:.2f} seconds")
return SearchResponse(streams=cached_result)
@ -333,8 +399,69 @@ async def get_results(
nonlocal search_results
search_results = []
if config["cache"] and not update_cache:
public_cached_results = search_public(media)
async def _fetch_c411_raw():
if not config.get("c411"):
return []
try:
c411_service = C411Service(config, session=http_session)
raw = await c411_service.search(media)
return [
C411SearchResult().from_api_item(item, media)
for item in raw
if getattr(item, "info_hash", None) and len(item.info_hash) == 40
] if raw else []
except Exception as e:
logger.warning(f"Search: C411 search failed, skipping: {str(e)}")
return []
async def _fetch_torr9_raw():
if not config.get("torr9"):
return []
try:
torr9_service = Torr9Service(config, session=http_session)
raw = await torr9_service.search(media)
return [
Torr9SearchResult().from_api_item(item, media)
for item in raw
if getattr(item, "info_hash", None) and len(item.info_hash) == 40
] if raw else []
except Exception as e:
logger.warning(f"Search: Torr9 search failed, skipping: {str(e)}")
return []
async def _fetch_yggflix_raw():
if not config.get("yggflix"):
return []
try:
yggflix_service = YggflixService(config)
raw = await asyncio.to_thread(yggflix_service.search, media)
return raw if raw else []
except Exception as e:
logger.warning(f"Search: Yggflix search failed, skipping: {str(e)}")
return []
c411_raw, torr9_raw, yggflix_raw = await asyncio.gather(
_fetch_c411_raw(), _fetch_torr9_raw(), _fetch_yggflix_raw()
)
if c411_raw:
c411_search_results = await torrent_service.convert_and_process(c411_raw)
logger.success(f"Search: Found {len(c411_search_results)} results from C411")
search_results = merge_items(search_results, c411_search_results)
if torr9_raw:
torr9_search_results = await torrent_service.convert_and_process(torr9_raw)
logger.success(f"Search: Found {len(torr9_search_results)} results from Torr9")
search_results = merge_items(search_results, torr9_search_results)
if yggflix_raw:
yggflix_search_results = await torrent_service.convert_and_process(yggflix_raw)
logger.success(f"Search: Found {len(yggflix_search_results)} results from Yggflix")
search_results = merge_items(search_results, yggflix_search_results)
# 2. Public cache (DMM etc.)
if config["cache"] and not update_cache and len(search_results) < int(
config["minCachedResults"]
):
public_cached_results = await asyncio.to_thread(search_public, media)
if public_cached_results:
logger.success(
f"Search: Found {len(public_cached_results)} public cached results"
@ -344,18 +471,17 @@ async def get_results(
for torrent in public_cached_results
if isinstance(torrent, dict) and len(torrent.get("hash", "")) == 40
]
public_cached_results = filter_items(
public_cached_results, media, config=config
)
public_cached_results = await torrent_service.convert_and_process(
public_cached_results
)
search_results.extend(public_cached_results)
search_results = merge_items(search_results, public_cached_results)
# Prioriser Zilean en premier
if config["zilean"]:
zilean_service = ZileanService(config)
zilean_search_results = zilean_service.search(media)
# 3. Zilean si pas assez de résultats
if config["zilean"] and len(search_results) < int(
config["minCachedResults"]
):
zilean_service = ZileanService(config, session=http_session)
zilean_search_results = await zilean_service.search(media)
if zilean_search_results:
logger.success(
f"Search: Found {len(zilean_search_results)} results from Zilean"
@ -365,9 +491,6 @@ async def get_results(
for torrent in zilean_search_results
if len(getattr(torrent, "info_hash", "")) == 40
]
zilean_search_results = filter_items(
zilean_search_results, media, config=config
)
zilean_search_results = await torrent_service.convert_and_process(
zilean_search_results
)
@ -376,37 +499,16 @@ async def get_results(
)
search_results = merge_items(search_results, zilean_search_results)
# Ensuite YggFlix si pas assez de résultats
if config["yggflix"] and len(search_results) < int(
config["minCachedResults"]
):
yggflix_service = YggflixService(config)
yggflix_search_results = yggflix_service.search(media)
if yggflix_search_results:
logger.success(
f"Search: Found {len(yggflix_search_results)} results from YggFlix"
)
yggflix_search_results = filter_items(
yggflix_search_results, media, config=config
)
yggflix_search_results = await torrent_service.convert_and_process(
yggflix_search_results
)
search_results = merge_items(search_results, yggflix_search_results)
if config["sharewood"] and len(search_results) < int(
config["minCachedResults"]
):
try:
sharewood_service = SharewoodService(config)
sharewood_search_results = sharewood_service.search(media)
sharewood_service = SharewoodService(config, session=http_session)
sharewood_search_results = await sharewood_service.search(media)
if sharewood_search_results:
logger.success(
f"Search: Found {len(sharewood_search_results)} results from Sharewood"
)
sharewood_search_results = filter_items(
sharewood_search_results, media, config=config
)
sharewood_search_results = (
await torrent_service.convert_and_process(
sharewood_search_results
@ -419,17 +521,14 @@ async def get_results(
if config["jackett"] and len(search_results) < int(
config["minCachedResults"]
):
jackett_service = JackettService(config)
jackett_search_results = jackett_service.search(media)
jackett_service = JackettService(config, session=http_session)
jackett_search_results = await jackett_service.search(media)
logger.success(
f"Search: Found {len(jackett_search_results)} results from Jackett"
)
filtered_jackett_search_results = filter_items(
jackett_search_results, media, config=config
)
if filtered_jackett_search_results:
if jackett_search_results:
torrent_results = await torrent_service.convert_and_process(
filtered_jackett_search_results
jackett_search_results
)
search_results = merge_items(search_results, torrent_results)
@ -449,38 +548,63 @@ async def get_results(
return search_results
async def get_and_filter_results(media, config):
min_results = int(config.get("minCachedResults", 5))
cache_key = media_cache_key(media)
# Postgres acts as a local cache for private indexers (Yggtorrent, C411, Torr9)
# and is always queried directly, bypassing Redis
postgres_results = []
if hasattr(media, 'tmdb_id') and media.tmdb_id:
try:
postgres_items = await torrent_dao.search_by_tmdb_id(int(media.tmdb_id))
if postgres_items:
logger.success(
f"Search: Found {len(postgres_items)} results from Postgres (local cache) for TMDB ID {media.tmdb_id}"
)
torrent_service = TorrentService(config, torrent_dao)
for db_item in postgres_items:
if db_item.indexer in ['Yggtorrent - API', 'C411 - API', 'Torr9 - API']:
torrent_item = db_item.to_torrent_item()
postgres_results.append(torrent_item)
except Exception as pg_error:
logger.error(f"Search: Postgres search failed: {str(pg_error)}")
unfiltered_results = await redis_cache.get(cache_key)
if unfiltered_results is None:
logger.debug("Search: No results in cache. Performing new search.")
nocache_results = await get_search_results(media, config)
nocache_results_dict = [item.to_dict() for item in nocache_results]
await redis_cache.set(cache_key, nocache_results_dict, expiration=settings.redis_expiration)
logger.info(
f"Search: New search completed, found {len(nocache_results)} results"
cache_key = media_cache_key(media)
external_results = await redis_cache.get(cache_key)
if external_results is None:
logger.info("Search: No external sources in Redis cache. Performing new search.")
external_results = await get_search_results(media, config)
external_results_dict = [item.to_dict() for item in external_results]
await redis_cache.set(cache_key, external_results_dict, expiration=settings.redis_expiration)
logger.success(
f"Search: Cached {len(external_results)} external results in Redis (Sharewood/Zilean/Jackett)"
)
return nocache_results
else:
logger.info(
f"Search: Retrieved {len(unfiltered_results)} results from redis cache"
logger.success(
f"Search: Retrieved {len(external_results)} external results from Redis cache"
)
unfiltered_results = [
TorrentItem.from_dict(item) for item in unfiltered_results
external_results = [
TorrentItem.from_dict(item) for item in external_results
]
filtered_results = filter_items(unfiltered_results, media, config=config)
all_results = merge_items(postgres_results, external_results)
logger.info(f"Search: Merged Postgres ({len(postgres_results)}) + External ({len(external_results)}) = {len(all_results)} total results")
if len(filtered_results) < min_results:
logger.info(
f"Search: Insufficient filtered results ({len(filtered_results)}). Performing new search."
filtered_results = filter_items(all_results, media, config=config)
min_results = int(config.get("minCachedResults", 8))
external_filtered = filter_items(external_results, media, config=config)
if len(external_filtered) < min_results:
logger.warning(
f"Search: Insufficient external results ({len(external_filtered)} < {min_results}). Recreating external cache."
)
await redis_cache.delete(cache_key)
unfiltered_results = await get_search_results(media, config)
unfiltered_results_dict = [item.to_dict() for item in unfiltered_results]
await redis_cache.set(cache_key, unfiltered_results_dict, expiration=settings.redis_expiration)
filtered_results = filter_items(unfiltered_results, media, config=config)
external_results = await get_search_results(media, config)
external_results_dict = [item.to_dict() for item in external_results]
await redis_cache.set(cache_key, external_results_dict, expiration=settings.redis_expiration)
logger.success(
f"Search: Recreated external cache with {len(external_results)} results"
)
all_results = merge_items(postgres_results, external_results)
filtered_results = filter_items(all_results, media, config=config)
logger.success(
f"Search: Final number of filtered results: {len(filtered_results)}"
@ -492,26 +616,24 @@ 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):
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)
ip = get_client_ip(request)
result = await debrid.get_availability_bulk(hashes, ip)
if result:
torrent_smart_container.update_availability(
result, type(debrid), media
)
# Gérer à la fois les dictionnaires et les listes
if isinstance(result, dict):
count = len(result.items())
else: # Si c'est une liste (comme pour StremThru)
else:
count = len(result)
# Déterminer si c'est StremThru pour ajuster la durée du cache
is_stremthru = (type(debrid).__name__ == "StremThru" or
is_stremthru = (type(debrid).__name__ == "StremThru" or
hasattr(debrid, 'store_name') and getattr(debrid, 'store_name', None) is not None)
logger.info(
@ -523,7 +645,6 @@ async def get_results(
)
if config["cache"]:
logger.info("Search: Caching public container items")
torrent_smart_container.cache_container_items()
best_matching_results = torrent_smart_container.get_best_matching()
@ -531,38 +652,28 @@ async def get_results(
logger.info(f"Search: Found {len(best_matching_results)} best matching results")
parser = StreamParser(config)
stream_list = parser.parse_to_stremio_streams(best_matching_results, media)
stream_list = await parser.parse_to_stremio_streams(best_matching_results, media)
logger.success(f"Search: Processed {len(stream_list)} streams for Stremio")
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]
# Définir la durée d'expiration par défaut à 1200 secondes
expiration_time = 1200
# Vérifier si StremThru est utilisé pour ajuster la durée du cache
has_stremthru = False
for debrid in debrid_services:
if type(debrid).__name__ == "StremThru" or hasattr(debrid, 'store_name'):
has_stremthru = True
break
# Si StremThru est utilisé, utiliser la durée de cache spécifique
has_stremthru = any(
type(debrid).__name__ == "StremThru" or hasattr(debrid, 'store_name')
for debrid in debrid_services
)
if has_stremthru:
expiration_time = 600
logger.info(f"Search: Using reduced cache expiration time of {expiration_time} seconds for StremThru")
# Mettre en cache les résultats IMMÉDIATEMENT
logger.info(f"Search: Using reduced cache expiration ({expiration_time}s) for StremThru")
await redis_cache.set(stream_cache_key(media), streams, expiration=expiration_time)
# Pre-fetch complet de l'épisode suivant en arrière-plan (non-bloquant)
if isinstance(media, Series):
asyncio.create_task(full_prefetch_from_cache(media, config, redis_cache, stream_cache_key, get_metadata, stream_type, debrid_services, torrent_dao, request))
total_time = time.time() - start
logger.info(f"Search: Request completed in {total_time:.2f} seconds")
return SearchResponse(streams=streams)