Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88743c101d |
63 changed files with 2363 additions and 4019 deletions
9
.github/workflows/docker-release.yml
vendored
9
.github/workflows/docker-release.yml
vendored
|
|
@ -3,11 +3,6 @@ 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:
|
||||
|
|
@ -16,8 +11,6 @@ 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
|
||||
|
|
@ -42,7 +35,7 @@ jobs:
|
|||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/telkaoss/stream-fusion:latest
|
||||
ghcr.io/telkaoss/stream-fusion:${{ github.event.inputs.tag || github.event.release.tag_name }}
|
||||
ghcr.io/telkaoss/stream-fusion:${{ github.event.release.tag_name }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.description=Docker image for Stream Fusion
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ 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")
|
||||
|
|
|
|||
|
|
@ -116,7 +116,6 @@ 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):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from typing import List, Optional
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from stream_fusion.services.postgresql.dependencies import get_db_session
|
||||
from stream_fusion.services.postgresql.models.torrentitem_model import TorrentItemModel
|
||||
|
|
@ -10,6 +10,7 @@ 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
|
||||
|
|
@ -22,11 +23,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:
|
||||
if "duplicate key value violates unique constraint" not in str(e):
|
||||
logger.error(f"TorrentItemDAO: Error creating TorrentItem: {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():
|
||||
|
|
@ -66,18 +67,14 @@ 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:
|
||||
|
|
@ -113,7 +110,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:
|
||||
|
|
@ -125,7 +122,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:
|
||||
|
|
@ -161,320 +158,4 @@ class TorrentItemDAO:
|
|||
return items
|
||||
except Exception as e:
|
||||
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by availability {available}: {str(e)}")
|
||||
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 []
|
||||
return None
|
||||
|
|
@ -1,12 +1,34 @@
|
|||
"""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")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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
|
||||
|
|
@ -15,8 +16,10 @@ 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')
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
"""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')
|
||||
|
|
@ -25,8 +25,7 @@ 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)
|
||||
|
|
@ -41,8 +40,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", info_hash: str = "") -> str:
|
||||
unique_string = f"{raw_title}_{size}_{indexer}_{info_hash}"
|
||||
def generate_unique_id(raw_title: str, size: int, indexer: str = "cached") -> str:
|
||||
unique_string = f"{raw_title}_{size}_{indexer}"
|
||||
full_hash = hashlib.sha256(unique_string.encode()).hexdigest()
|
||||
return full_hash[:16]
|
||||
|
||||
|
|
@ -51,8 +50,7 @@ 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('info_hash', '')
|
||||
kwargs.get('indexer', 'cached')
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
current_time = int(datetime.now().timestamp())
|
||||
|
|
@ -61,24 +59,6 @@ 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 = {}
|
||||
|
|
@ -87,32 +67,13 @@ class TorrentItemModel(Base):
|
|||
if attr == 'size':
|
||||
model_dict[attr] = cls._parse_size(value)
|
||||
elif attr in ['files', 'full_index']:
|
||||
# Remove ed2k bytes before JSON serialization
|
||||
cleaned_value = cls._remove_ed2k_from_files(value)
|
||||
model_dict[attr] = cls._parse_json(cleaned_value)
|
||||
model_dict[attr] = cls._parse_json(value)
|
||||
elif attr == 'parsed_data':
|
||||
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
|
||||
model_dict[attr] = value.model_dump() if value else 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
|
||||
|
||||
|
|
@ -120,30 +81,13 @@ 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 == '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
|
||||
if attr == 'parsed_data':
|
||||
torrent_item_dict[attr] = ParsedData(**value) if value else None
|
||||
else:
|
||||
torrent_item_dict[attr] = value
|
||||
|
||||
|
|
@ -169,4 +113,4 @@ class TorrentItemModel(Base):
|
|||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return value
|
||||
return value
|
||||
|
|
@ -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 10.
|
||||
Returns the number of CPU cores multiplied by 2, with a minimum of 2 and a maximum of 6.
|
||||
"""
|
||||
return min(max(multiprocessing.cpu_count() * 2, 2), 8)
|
||||
return min(max(multiprocessing.cpu_count() * 2, 2), 6)
|
||||
|
||||
|
||||
def check_env_variable(var_name):
|
||||
|
|
@ -135,14 +135,12 @@ 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 # 7 jours
|
||||
redis_expiration: int = 604800
|
||||
redis_password: str | None = None
|
||||
|
||||
# TMDB
|
||||
|
|
@ -192,17 +190,6 @@ 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/"
|
||||
|
||||
|
|
@ -296,20 +283,6 @@ 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()
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ 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 () {
|
||||
|
|
@ -13,17 +14,6 @@ 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) {
|
||||
|
|
@ -198,7 +188,7 @@ function pollForADCredentials(check, pin, expiresIn) {
|
|||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data === null) return;
|
||||
if (data === null) return; // Skip processing if user hasn't entered PIN yet
|
||||
if (data.data && data.data.activated && data.data.apikey) {
|
||||
clearInterval(pollInterval);
|
||||
clearTimeout(timeoutId);
|
||||
|
|
@ -232,7 +222,7 @@ function resetADAuthButton() {
|
|||
}
|
||||
|
||||
function handleUniqueAccounts() {
|
||||
const accounts = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix', 'c411', 'torr9'];
|
||||
const accounts = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix'];
|
||||
|
||||
accounts.forEach(account => {
|
||||
const checkbox = document.getElementById(account);
|
||||
|
|
@ -518,16 +508,13 @@ 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 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;
|
||||
const tbChecked = document.getElementById('debrid_tb')?.checked || document.getElementById('debrid_tb')?.disabled;
|
||||
|
||||
// Afficher/masquer les champs spécifiques
|
||||
setElementDisplay('cache-fields', cacheChecked ? 'block' : 'none');
|
||||
setElementDisplay('ygg-fields', yggflixChecked ? 'block' : 'none');
|
||||
setElementDisplay('sharewood-fields', sharewoodChecked ? 'block' : 'none');
|
||||
setElementDisplay('tb_debrid-fields', torboxChecked ? 'block' : 'none');
|
||||
setElementDisplay('c411-fields', c411Checked ? 'block' : 'none');
|
||||
setElementDisplay('torr9-fields', torr9Checked ? 'block' : 'none');
|
||||
setElementDisplay('tb_debrid-fields', tbChecked ? 'block' : 'none');
|
||||
|
||||
// Traiter tous les débrideurs
|
||||
allDebrids.forEach(id => {
|
||||
|
|
@ -689,7 +676,7 @@ function loadData() {
|
|||
zilean: true,
|
||||
yggflix: true,
|
||||
sharewood: false,
|
||||
maxSize: '150',
|
||||
maxSize: '18',
|
||||
resultsPerQuality: '10',
|
||||
maxResults: '30',
|
||||
minCachedResults: '10',
|
||||
|
|
@ -697,8 +684,8 @@ function loadData() {
|
|||
ctg_yggtorrent: true,
|
||||
ctg_yggflix: false,
|
||||
metadataProvider: 'tmdb',
|
||||
sort: 'quality',
|
||||
exclusion: ['cam'],
|
||||
sort: 'qualitythensize',
|
||||
exclusion: ['cam', '2160p'],
|
||||
languages: ['fr', 'multi'],
|
||||
debrid_rd: false,
|
||||
debrid_ad: false,
|
||||
|
|
@ -706,9 +693,7 @@ function loadData() {
|
|||
debrid_pm: false,
|
||||
tb_usenet: false,
|
||||
tb_search: false,
|
||||
debrid_order: false,
|
||||
c411: true,
|
||||
torr9: true
|
||||
debrid_order: false
|
||||
};
|
||||
|
||||
Object.keys(defaultConfig).forEach(key => {
|
||||
|
|
@ -749,8 +734,6 @@ 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(', '), '');
|
||||
|
||||
|
|
@ -773,50 +756,10 @@ 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: apiKey,
|
||||
apiKey: document.getElementById('ApiKey').value,
|
||||
service: [],
|
||||
RDToken: document.getElementById('rd_token_info')?.value,
|
||||
ADToken: document.getElementById('ad_token_info')?.value,
|
||||
|
|
@ -825,10 +768,6 @@ 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),
|
||||
|
|
@ -846,7 +785,7 @@ function getLink(method) {
|
|||
yggtorrentCtg: document.getElementById('ctg_yggtorrent')?.checked,
|
||||
yggflixCtg: document.getElementById('ctg_yggflix')?.checked,
|
||||
yggPasskey: document.getElementById('yggPasskey')?.value,
|
||||
torrenting: false,
|
||||
torrenting: document.getElementById('torrenting').checked,
|
||||
debrid: false,
|
||||
metadataProvider: document.getElementById('tmdb').checked ? 'tmdb' : 'cinemeta',
|
||||
debridDownloader: document.querySelector('input[name="debrid_downloader"]:checked')?.value,
|
||||
|
|
@ -875,9 +814,8 @@ 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) {
|
||||
|
|
@ -889,6 +827,10 @@ 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;
|
||||
|
|
@ -899,6 +841,11 @@ 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`;
|
||||
|
||||
|
|
@ -920,33 +867,3 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,6 @@
|
|||
<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>
|
||||
|
|
@ -241,6 +238,19 @@
|
|||
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>
|
||||
|
||||
|
|
@ -362,32 +372,6 @@
|
|||
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>
|
||||
|
||||
|
|
@ -529,7 +513,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">
|
||||
|
|
@ -656,38 +640,19 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if not c411_unique_account %}
|
||||
<div id="c411-fields" style="display: none;">
|
||||
{% if not ygg_unique_account %}
|
||||
<div id="ygg-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">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>
|
||||
<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>
|
||||
</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="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"
|
||||
<label for="yggPasskey"
|
||||
class="block text-sm font-medium leading-6 text-white">YggTorrent PassKey</label>
|
||||
<input type="text" name="yggPasskey" id="yggPasskey"
|
||||
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>
|
||||
|
|
@ -706,7 +671,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" checked
|
||||
<input id="quality" name="sorting" type="radio"
|
||||
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
|
||||
|
|
@ -727,7 +692,7 @@
|
|||
size ascending</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-3">
|
||||
<input id="qualitythensize" name="sorting" type="radio"
|
||||
<input id="qualitythensize" name="sorting" type="radio" checked
|
||||
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
|
||||
|
|
@ -780,7 +745,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"
|
||||
<input id="2160p" name="2160p" type="checkbox" checked
|
||||
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
|
||||
|
|
@ -801,7 +766,7 @@
|
|||
resolution</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-3">
|
||||
<input id="480p" name="480p" type="checkbox" checked
|
||||
<input id="480p" name="480p" type="checkbox"
|
||||
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
|
||||
|
|
@ -886,7 +851,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>
|
||||
|
|
@ -912,7 +877,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="200"
|
||||
<input type="number" name="maxSize" id="maxSize" min="0" step="0.1" value="18"
|
||||
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>
|
||||
|
||||
|
|
@ -942,9 +907,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.
Binary file not shown.
|
|
@ -1,153 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
|
||||
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
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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
|
||||
6
stream_fusion/utils/cache/cache.py
vendored
6
stream_fusion/utils/cache/cache.py
vendored
|
|
@ -11,9 +11,6 @@ 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?
|
||||
|
|
@ -34,9 +31,6 @@ def search_public(media):
|
|||
|
||||
|
||||
def cache_public(torrents: List[TorrentItem], media):
|
||||
if not settings.public_cache_url:
|
||||
return
|
||||
|
||||
if os.getenv("NODE_ENV") == "development":
|
||||
return
|
||||
|
||||
|
|
|
|||
17
stream_fusion/utils/cache/local_redis.py
vendored
17
stream_fusion/utils/cache/local_redis.py
vendored
|
|
@ -25,9 +25,7 @@ class RedisCache(CacheBase):
|
|||
try:
|
||||
self._redis_client = Redis.from_url(
|
||||
self.redis_url,
|
||||
max_connections=100,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=10
|
||||
max_connections=10
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"RedisCache: Failed to create Redis client: {e}")
|
||||
|
|
@ -218,19 +216,10 @@ class RedisCache(CacheBase):
|
|||
|
||||
async def close(self):
|
||||
if self._redis_client:
|
||||
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}")
|
||||
await self._redis_client.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
await self.close()
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
# alldebrid.py - AllDebrid v4.1 API (using magnet/status for files)
|
||||
# alldebrid.py
|
||||
import uuid
|
||||
import aiohttp
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -11,29 +10,9 @@ 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, session: aiohttp.ClientSession = None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
self.base_url = f"{settings.ad_base_url}/{settings.ad_api_version}/"
|
||||
self.agent = settings.ad_user_app
|
||||
|
||||
|
|
@ -51,166 +30,111 @@ class AllDebrid(BaseDebrid):
|
|||
else:
|
||||
return {"Authorization": f"Bearer {self.config.get('ADToken')}"}
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
def add_magnet(self, magnet, ip=None):
|
||||
url = f"{self.base_url}magnet/upload?agent={self.agent}"
|
||||
data = {"magnets[]": magnet}
|
||||
return await self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||
return self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||
|
||||
async def add_torrent(self, torrent_file, ip=None):
|
||||
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 await self.json_response(url, method='post', headers=self.get_headers(), files=files)
|
||||
return self.json_response(url, method='post', headers=self.get_headers(), files=files)
|
||||
|
||||
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 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 unrestrict_link(self, link, ip=None):
|
||||
def unrestrict_link(self, link, ip=None):
|
||||
url = f"{self.base_url}link/unlock?agent={self.agent}&link={link}"
|
||||
return await self.json_response(url, method='get', headers=self.get_headers())
|
||||
return self.json_response(url, method='get', headers=self.get_headers())
|
||||
|
||||
async def get_stream_link(self, query, config=None, ip=None):
|
||||
def get_stream_link(self, query, config, 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)
|
||||
|
||||
# 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 ""
|
||||
torrent_id = self.add_magnet_or_torrent(magnet, torrent_download, ip)
|
||||
logger.info(f"AllDebrid: Torrent ID: {torrent_id}")
|
||||
|
||||
if not torrent_id or torrent_id.startswith("Error"):
|
||||
logger.error(f"AllDebrid: Failed to add torrent: {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.")
|
||||
return settings.no_cache_video_url
|
||||
logger.info("AllDebrid: Torrent is ready.")
|
||||
|
||||
# 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
|
||||
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")
|
||||
|
||||
link = settings.no_cache_video_url
|
||||
|
||||
if stream_type == "movie":
|
||||
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)}")
|
||||
|
||||
logger.info("AllDebrid: Getting link for movie")
|
||||
link = max(data["magnets"]['links'], key=lambda x: x['size'])['link']
|
||||
elif stream_type == "series":
|
||||
numeric_season = int(query['season'].replace("S", ""))
|
||||
numeric_episode = int(query['episode'].replace("E", ""))
|
||||
logger.info(f"AllDebrid: Finding S{numeric_season:02d}E{numeric_episode:02d}")
|
||||
logger.info(f"AllDebrid: Getting link for series S{numeric_season:02d}E{numeric_episode:02d}")
|
||||
|
||||
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")
|
||||
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)
|
||||
else:
|
||||
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)}")
|
||||
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}")
|
||||
|
||||
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: No link found, returning no-cache URL")
|
||||
logger.info("AllDebrid: Video not cached, returning NO_CACHE_VIDEO_URL")
|
||||
return link
|
||||
|
||||
logger.info(f"AllDebrid: Retrieved link successfully")
|
||||
logger.info(f"AllDebrid: Retrieved link: {link}")
|
||||
|
||||
# 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)}")
|
||||
unlocked_link_data = self.unrestrict_link(link, ip)
|
||||
|
||||
return link
|
||||
if not unlocked_link_data:
|
||||
logger.error("AllDebrid: Failed to unlock link.")
|
||||
raise HTTPException(status_code=500, detail="Failed to unlock link in AllDebrid.")
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
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):
|
||||
if len(hashes_or_magnets) == 0:
|
||||
logger.info("AllDebrid: No hashes to check")
|
||||
logger.info("AllDebrid: No hashes to be sent.")
|
||||
return {"status": "success", "data": {"magnets": []}}
|
||||
|
||||
result_magnets = []
|
||||
|
|
@ -231,59 +155,30 @@ class AllDebrid(BaseDebrid):
|
|||
|
||||
return {"status": "success", "data": {"magnets": result_magnets}}
|
||||
|
||||
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")
|
||||
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
||||
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")
|
||||
|
||||
# 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)}")
|
||||
if not magnet_response or "status" not in magnet_response or magnet_response["status"] != "success":
|
||||
return "Error: Failed to add magnet."
|
||||
|
||||
# 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)
|
||||
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")
|
||||
|
||||
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)}")
|
||||
logger.info(f"AllDebrid: Adding torrent file")
|
||||
upload_response = self.add_torrent(torrent_file, ip)
|
||||
logger.info(f"AllDebrid: Add torrent file response received")
|
||||
|
||||
# PRIORITE 3: Fall back to magnet
|
||||
logger.info(f"AllDebrid: Adding magnet link")
|
||||
magnet_response = await self.add_magnet(magnet, ip)
|
||||
if not upload_response or "status" not in upload_response or upload_response["status"] != "success":
|
||||
return "Error: Failed to add torrent file in AllDebrid."
|
||||
|
||||
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."
|
||||
torrent_id = upload_response["data"]["files"][0]["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."
|
||||
logger.info(f"AllDebrid: New torrent ID: {torrent_id}")
|
||||
return torrent_id
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
from collections import deque
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
from aiohttp_socks import ProxyConnector
|
||||
import requests
|
||||
|
||||
from stream_fusion.logging_config import logger
|
||||
from stream_fusion.settings import settings
|
||||
|
||||
|
||||
class BaseDebrid:
|
||||
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self._external_session = session is not None
|
||||
self._session = session
|
||||
self.__session = self._create_session()
|
||||
|
||||
# Rate limiters
|
||||
self.global_limit = 250
|
||||
|
|
@ -25,28 +23,17 @@ class BaseDebrid:
|
|||
self.global_requests = deque()
|
||||
self.torrent_requests = deque()
|
||||
|
||||
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 _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
|
||||
|
||||
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."""
|
||||
def _rate_limit(self, requests_queue, limit, period):
|
||||
current_time = time.time()
|
||||
|
||||
while requests_queue and requests_queue[0] <= current_time - period:
|
||||
|
|
@ -55,83 +42,63 @@ class BaseDebrid:
|
|||
if len(requests_queue) >= limit:
|
||||
sleep_time = requests_queue[0] - (current_time - period)
|
||||
if sleep_time > 0:
|
||||
await asyncio.sleep(sleep_time)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
requests_queue.append(time.time())
|
||||
|
||||
async def _global_rate_limit(self):
|
||||
await self._rate_limit(self.global_requests, self.global_limit, self.global_period)
|
||||
def _global_rate_limit(self):
|
||||
self._rate_limit(self.global_requests, self.global_limit, self.global_period)
|
||||
|
||||
async def _torrent_rate_limit(self):
|
||||
await self._rate_limit(self.torrent_requests, self.torrent_limit, self.torrent_period)
|
||||
def _torrent_rate_limit(self):
|
||||
self._rate_limit(self.torrent_requests, self.torrent_limit, self.torrent_period)
|
||||
|
||||
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()
|
||||
def json_response(self, url, method="get", data=None, headers=None, files=None):
|
||||
self._global_rate_limit()
|
||||
if "torrents" in url:
|
||||
await self._torrent_rate_limit()
|
||||
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":
|
||||
async with session.get(url, **kwargs) as response:
|
||||
await self._log_and_raise(response)
|
||||
return await self._parse_json_response(response, attempt, max_attempts)
|
||||
|
||||
response = self.__session.get(url, headers=headers)
|
||||
elif method == "post":
|
||||
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)
|
||||
|
||||
response = self.__session.post(
|
||||
url, data=data, headers=headers, files=files
|
||||
)
|
||||
elif method == "put":
|
||||
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)
|
||||
|
||||
response = self.__session.put(url, data=data, headers=headers)
|
||||
elif method == "delete":
|
||||
async with session.delete(url, **kwargs) as response:
|
||||
await self._log_and_raise(response)
|
||||
return await self._parse_json_response(response, attempt, max_attempts)
|
||||
response = self.__session.delete(url, headers=headers)
|
||||
else:
|
||||
raise ValueError(f"BaseDebrid: Unsupported HTTP method: {method}")
|
||||
|
||||
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.")
|
||||
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
|
||||
if status_code == 429:
|
||||
wait_time = 2**attempt + 1
|
||||
self.logger.warning(
|
||||
f"BaseDebrid: Rate limit exceeded. Attempt {attempt + 1}/{max_attempts}. Waiting for {wait_time} seconds."
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
time.sleep(wait_time)
|
||||
elif 400 <= status_code < 500:
|
||||
self.logger.error(
|
||||
f"BaseDebrid: Client error occurred: {e}. Status code: {status_code}"
|
||||
|
|
@ -146,7 +113,7 @@ class BaseDebrid:
|
|||
self.logger.info(
|
||||
f"BaseDebrid: Retrying in {wait_time} seconds..."
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
|
|
@ -154,26 +121,23 @@ class BaseDebrid:
|
|||
f"BaseDebrid: Unexpected HTTP error occurred: {e}. Status code: {status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
except requests.exceptions.ConnectionError 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...")
|
||||
await asyncio.sleep(wait_time)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
self.logger.error(f"BaseDebrid: Request timed out")
|
||||
except requests.exceptions.Timeout as e:
|
||||
self.logger.error(f"BaseDebrid: Request timed out: {e}")
|
||||
if attempt < max_attempts - 1:
|
||||
wait_time = 2**attempt + 1
|
||||
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
|
||||
await asyncio.sleep(wait_time)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
return None
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"BaseDebrid: An unexpected error occurred: {e}")
|
||||
return None
|
||||
|
||||
|
|
@ -182,61 +146,30 @@ class BaseDebrid:
|
|||
)
|
||||
return None
|
||||
|
||||
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."""
|
||||
def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
|
||||
self.logger.info(f"BaseDebrid: Waiting for {timeout} seconds for caching.")
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if await check_status_func():
|
||||
if check_status_func():
|
||||
self.logger.info("BaseDebrid: File is ready!")
|
||||
return True
|
||||
await asyncio.sleep(interval)
|
||||
time.sleep(interval)
|
||||
self.logger.info(f"BaseDebrid: Waiting timed out.")
|
||||
return False
|
||||
|
||||
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 download_torrent_file(self, download_url):
|
||||
response = requests.get(download_url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
async def get_stream_link(self, query, ip=None):
|
||||
def get_stream_link(self, query, ip=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
||||
raise NotImplementedError
|
||||
|
||||
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
||||
def add_magnet(self, magnet, ip=None):
|
||||
raise NotImplementedError
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
raise NotImplementedError
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
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
|
||||
|
|
@ -3,31 +3,31 @@ from stream_fusion.logging_config import logger
|
|||
|
||||
|
||||
class DebridLink(StremThru):
|
||||
def __init__(self, config, session=None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
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)
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
|
||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
"""Vérifie la disponibilité des torrents en masse via StremThru"""
|
||||
results = await super().get_availability_bulk(hashes_or_magnets, ip)
|
||||
results = super().get_availability_bulk(hashes_or_magnets, ip)
|
||||
logger.debug(f"DebridLink (via StremThru): {len(results)} torrents en cache trouvés")
|
||||
return results
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
|
||||
def add_magnet(self, magnet, ip=None):
|
||||
"""Ajoute un magnet à DebridLink via StremThru"""
|
||||
result = await super().add_magnet(magnet, ip)
|
||||
result = super().add_magnet(magnet, ip)
|
||||
logger.debug(f"DebridLink (via StremThru): Magnet ajouté avec succès: {result is not None}")
|
||||
return result
|
||||
|
||||
async def get_stream_link(self, query, config=None, ip=None):
|
||||
|
||||
def get_stream_link(self, query, ip=None):
|
||||
"""Génère un lien de streaming via StremThru"""
|
||||
link = await super().get_stream_link(query, config, ip)
|
||||
link = super().get_stream_link(query, ip)
|
||||
logger.debug(f"DebridLink (via StremThru): Lien de streaming généré: {link is not None}")
|
||||
return link
|
||||
|
|
|
|||
|
|
@ -3,31 +3,31 @@ from stream_fusion.logging_config import logger
|
|||
|
||||
|
||||
class EasyDebrid(StremThru):
|
||||
def __init__(self, config, session=None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
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)
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
|
||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
"""Vérifie la disponibilité des torrents en masse via StremThru"""
|
||||
results = await super().get_availability_bulk(hashes_or_magnets, ip)
|
||||
results = super().get_availability_bulk(hashes_or_magnets, ip)
|
||||
logger.debug(f"EasyDebrid (via StremThru): {len(results)} torrents en cache trouvés")
|
||||
return results
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
|
||||
def add_magnet(self, magnet, ip=None):
|
||||
"""Ajoute un magnet à EasyDebrid via StremThru"""
|
||||
result = await super().add_magnet(magnet, ip)
|
||||
result = super().add_magnet(magnet, ip)
|
||||
logger.debug(f"EasyDebrid (via StremThru): Magnet ajouté avec succès: {result is not None}")
|
||||
return result
|
||||
|
||||
async def get_stream_link(self, query, config=None, ip=None):
|
||||
|
||||
def get_stream_link(self, query, ip=None):
|
||||
"""Génère un lien de streaming via StremThru"""
|
||||
link = await super().get_stream_link(query, config, ip)
|
||||
link = super().get_stream_link(query, ip)
|
||||
logger.debug(f"EasyDebrid (via StremThru): Lien de streaming généré: {link is not None}")
|
||||
return link
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import aiohttp
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
||||
from stream_fusion.utils.debrid.alldebrid import AllDebrid
|
||||
|
|
@ -14,111 +13,120 @@ from stream_fusion.logging_config import logger
|
|||
from stream_fusion.settings import settings
|
||||
|
||||
|
||||
def get_all_debrid_services(config, session: aiohttp.ClientSession = None):
|
||||
def get_all_debrid_services(config):
|
||||
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:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour Real-Debrid
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
|
||||
st.extension = "ST:RD"
|
||||
st.extension = "ST:RD" # Préfixe ST pour indiquer StremThru
|
||||
debrid_service.append(st)
|
||||
logger.debug("Real-Debrid (via StremThru): service added to be use")
|
||||
else:
|
||||
debrid_service.append(RealDebrid(config, session))
|
||||
debrid_service.append(RealDebrid(config))
|
||||
logger.debug("Real-Debrid: service added to be use")
|
||||
|
||||
|
||||
if service == "AllDebrid":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour AllDebrid
|
||||
st = StremThru(config)
|
||||
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, session))
|
||||
debrid_service.append(AllDebrid(config))
|
||||
logger.debug("AllDebrid: service added to be use")
|
||||
|
||||
|
||||
if service == "TorBox":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour TorBox
|
||||
st = StremThru(config)
|
||||
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, session))
|
||||
debrid_service.append(Torbox(config))
|
||||
logger.debug("TorBox: service added to be use")
|
||||
|
||||
|
||||
if service == "Premiumize":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour Premiumize
|
||||
st = StremThru(config)
|
||||
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, session))
|
||||
debrid_service.append(Premiumize(config))
|
||||
logger.debug("Premiumize: service added to be use")
|
||||
|
||||
|
||||
if service == "Debrid-Link":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour Debrid-Link
|
||||
st = StremThru(config)
|
||||
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, session))
|
||||
debrid_service.append(DebridLink(config))
|
||||
logger.debug("Debrid-Link: service added to be use")
|
||||
|
||||
|
||||
if service == "EasyDebrid":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour EasyDebrid
|
||||
st = StremThru(config)
|
||||
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, session))
|
||||
debrid_service.append(EasyDebrid(config))
|
||||
logger.debug("EasyDebrid: service added to be use")
|
||||
|
||||
|
||||
if service == "Offcloud":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour Offcloud
|
||||
st = StremThru(config)
|
||||
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, session))
|
||||
debrid_service.append(Offcloud(config))
|
||||
logger.debug("Offcloud: service added to be use")
|
||||
|
||||
|
||||
if service == "PikPak":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
# Utiliser StremThru pour PikPak
|
||||
st = StremThru(config)
|
||||
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, session))
|
||||
debrid_service.append(PikPak(config))
|
||||
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, session: aiohttp.ClientSession = None):
|
||||
def get_download_service(config):
|
||||
if not settings.download_service:
|
||||
service = config.get('debridDownloader')
|
||||
if not service:
|
||||
|
|
@ -135,57 +143,58 @@ def get_download_service(config, session: aiohttp.ClientSession = None):
|
|||
)
|
||||
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, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
|
||||
return st
|
||||
return RealDebrid(config, session)
|
||||
return RealDebrid(config)
|
||||
elif service == "AllDebrid":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
|
||||
return st
|
||||
return AllDebrid(config, session)
|
||||
return AllDebrid(config)
|
||||
elif service == "TorBox":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("torbox", config.get("TBToken", ""))
|
||||
return st
|
||||
return Torbox(config, session)
|
||||
return Torbox(config)
|
||||
elif service == "Premiumize":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("premiumize", config.get("PMToken", ""))
|
||||
return st
|
||||
return Premiumize(config, session)
|
||||
return Premiumize(config)
|
||||
elif service == "Debrid-Link":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("debridlink", config.get("DLToken", ""))
|
||||
return st
|
||||
return DebridLink(config, session)
|
||||
return DebridLink(config)
|
||||
elif service == "EasyDebrid":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
|
||||
return st
|
||||
return EasyDebrid(config, session)
|
||||
return EasyDebrid(config)
|
||||
elif service == "Offcloud":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
|
||||
return st
|
||||
return Offcloud(config, session)
|
||||
return Offcloud(config)
|
||||
elif service == "PikPak":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
|
||||
return st
|
||||
return PikPak(config, session)
|
||||
return PikPak(config)
|
||||
else:
|
||||
logger.error(f"Invalid download service: {service}")
|
||||
raise HTTPException(
|
||||
|
|
@ -194,62 +203,63 @@ def get_download_service(config, session: aiohttp.ClientSession = None):
|
|||
)
|
||||
|
||||
|
||||
def get_debrid_service(config, service, session: aiohttp.ClientSession = None):
|
||||
def get_debrid_service(config, service):
|
||||
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, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
|
||||
return st
|
||||
return RealDebrid(config, session)
|
||||
return RealDebrid(config)
|
||||
elif service == "AD":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
|
||||
return st
|
||||
return AllDebrid(config, session)
|
||||
return AllDebrid(config)
|
||||
elif service == "TB":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("torbox", config.get("TBToken", ""))
|
||||
return st
|
||||
return Torbox(config, session)
|
||||
return Torbox(config)
|
||||
elif service == "PM":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("premiumize", config.get("PMToken", ""))
|
||||
return st
|
||||
return Premiumize(config, session)
|
||||
return Premiumize(config)
|
||||
elif service == "DL":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("debridlink", config.get("DLToken", ""))
|
||||
return st
|
||||
return DebridLink(config, session)
|
||||
return DebridLink(config)
|
||||
elif service == "ED":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
|
||||
return st
|
||||
return EasyDebrid(config, session)
|
||||
return EasyDebrid(config)
|
||||
elif service == "OC":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
|
||||
return st
|
||||
return Offcloud(config, session)
|
||||
return Offcloud(config)
|
||||
elif service == "PP":
|
||||
if use_stremthru:
|
||||
st = StremThru(config, session)
|
||||
st = StremThru(config)
|
||||
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
|
||||
return st
|
||||
return PikPak(config, session)
|
||||
return PikPak(config)
|
||||
elif service == "ST":
|
||||
return get_download_service(config, session)
|
||||
return get_download_service(config)
|
||||
else:
|
||||
logger.error("Invalid service configuration return by stremio in the query.")
|
||||
raise HTTPException(status_code=500, detail="Invalid service configuration return by stremio.")
|
||||
|
|
|
|||
|
|
@ -3,32 +3,32 @@ from stream_fusion.logging_config import logger
|
|||
|
||||
|
||||
class Offcloud(StremThru):
|
||||
def __init__(self, config, session=None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
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)
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
|
||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
"""Vérifie la disponibilité des torrents en masse via StremThru"""
|
||||
results = await super().get_availability_bulk(hashes_or_magnets, ip)
|
||||
results = 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
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
|
||||
def add_magnet(self, magnet, ip=None):
|
||||
"""Ajoute un magnet à Offcloud via StremThru"""
|
||||
result = await super().add_magnet(magnet, ip)
|
||||
result = super().add_magnet(magnet, ip)
|
||||
logger.debug(f"Offcloud (via StremThru): Magnet ajouté avec succès: {result is not None}")
|
||||
return result
|
||||
|
||||
async def get_stream_link(self, query, config=None, ip=None):
|
||||
|
||||
def get_stream_link(self, query, ip=None):
|
||||
"""Génère un lien de streaming via StremThru"""
|
||||
link = await super().get_stream_link(query, config, ip)
|
||||
link = super().get_stream_link(query, ip)
|
||||
logger.debug(f"Offcloud (via StremThru): Lien de streaming généré: {link is not None}")
|
||||
return link
|
||||
|
|
|
|||
|
|
@ -3,31 +3,31 @@ from stream_fusion.logging_config import logger
|
|||
|
||||
|
||||
class PikPak(StremThru):
|
||||
def __init__(self, config, session=None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
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)
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
|
||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
"""Vérifie la disponibilité des torrents en masse via StremThru"""
|
||||
results = await super().get_availability_bulk(hashes_or_magnets, ip)
|
||||
results = super().get_availability_bulk(hashes_or_magnets, ip)
|
||||
logger.debug(f"PikPak (via StremThru): {len(results)} torrents en cache trouvés")
|
||||
return results
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
|
||||
def add_magnet(self, magnet, ip=None):
|
||||
"""Ajoute un magnet à PikPak via StremThru"""
|
||||
result = await super().add_magnet(magnet, ip)
|
||||
result = super().add_magnet(magnet, ip)
|
||||
logger.debug(f"PikPak (via StremThru): Magnet ajouté avec succès: {result is not None}")
|
||||
return result
|
||||
|
||||
async def get_stream_link(self, query, config=None, ip=None):
|
||||
|
||||
def get_stream_link(self, query, ip=None):
|
||||
"""Génère un lien de streaming via StremThru"""
|
||||
link = await super().get_stream_link(query, config, ip)
|
||||
link = super().get_stream_link(query, ip)
|
||||
logger.debug(f"PikPak (via StremThru): Lien de streaming généré: {link is not None}")
|
||||
return link
|
||||
|
|
|
|||
|
|
@ -1,66 +1,64 @@
|
|||
import asyncio
|
||||
import aiohttp
|
||||
import time
|
||||
import json
|
||||
|
||||
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, session: aiohttp.ClientSession = None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
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()
|
||||
|
||||
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):
|
||||
def _check_token(self):
|
||||
"""Vérifier la validité du token en appelant l'API account/info"""
|
||||
url = f"{self.base_url}/account/info"
|
||||
response = await self.json_response(
|
||||
response = 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")
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
await self._ensure_token_checked()
|
||||
def add_magnet(self, magnet, ip=None):
|
||||
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 = await self.json_response(url, method='post', data=form)
|
||||
|
||||
|
||||
response = 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:
|
||||
if await self._wait_for_season_pack(transfer_id):
|
||||
folder_details = await self.get_folder_or_file_details(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 folder_details and folder_details.get("content"):
|
||||
video_files = [f for f in folder_details["content"]
|
||||
# Trier les fichiers par taille pour prendre le plus gros fichier vidéo
|
||||
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))
|
||||
|
|
@ -71,14 +69,15 @@ 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",
|
||||
|
|
@ -93,80 +92,46 @@ class Premiumize(BaseDebrid):
|
|||
]
|
||||
return any(indicator in name for indicator in season_indicators)
|
||||
|
||||
async def _wait_for_season_pack(self, transfer_id, timeout=300, max_retries=10):
|
||||
def _wait_for_season_pack(self, transfer_id, timeout=300):
|
||||
"""Attend que tous les fichiers d'un pack de saison soient disponibles"""
|
||||
start_time = time.time()
|
||||
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")
|
||||
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)
|
||||
return False
|
||||
|
||||
async def add_torrent(self, torrent_file):
|
||||
await self._ensure_token_checked()
|
||||
def add_torrent(self, torrent_file):
|
||||
url = f"{self.base_url}/transfer/create?apikey={self.api_key}"
|
||||
form = {'file': torrent_file}
|
||||
return await self.json_response(url, method='post', data=form)
|
||||
return self.json_response(url, method='post', data=form)
|
||||
|
||||
async def list_transfers(self):
|
||||
await self._ensure_token_checked()
|
||||
def list_transfers(self):
|
||||
url = f"{self.base_url}/transfer/list?apikey={self.api_key}"
|
||||
return await self.json_response(url)
|
||||
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_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)
|
||||
|
||||
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):
|
||||
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 = await self.json_response(url)
|
||||
response = self.json_response(url)
|
||||
|
||||
if not response or response.get("status") != "success":
|
||||
logger.error("Invalid response from Premiumize API")
|
||||
|
|
@ -176,17 +141,21 @@ class Premiumize(BaseDebrid):
|
|||
"transcoded": response.get("transcoded", [False])
|
||||
}
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
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 = await self.json_response(
|
||||
response = self.json_response(
|
||||
url,
|
||||
method='post',
|
||||
data={
|
||||
|
|
@ -194,45 +163,49 @@ 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
|
||||
|
||||
async def start_background_caching(self, magnet, query=None):
|
||||
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:
|
||||
response = await self.json_response(
|
||||
# Create a transfer without waiting for completion
|
||||
response = self.json_response(
|
||||
f"{self.base_url}/transfer/create",
|
||||
method="post",
|
||||
data={"apikey": self.api_key, "src": magnet}
|
||||
|
|
@ -253,145 +226,130 @@ class Premiumize(BaseDebrid):
|
|||
logger.error(f"Error starting background caching: {str(e)}")
|
||||
return False
|
||||
|
||||
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()
|
||||
|
||||
def get_stream_link(self, query, config, ip=None):
|
||||
"""Get a stream link for a magnet link"""
|
||||
if not query:
|
||||
return None
|
||||
|
||||
logger.info(f"Getting stream link for magnet (global timeout: {global_timeout}s)")
|
||||
|
||||
try:
|
||||
season = None
|
||||
episode = None
|
||||
if isinstance(query, dict):
|
||||
magnet = query.get("magnet")
|
||||
if not magnet:
|
||||
logger.error("No magnet link in query")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
try:
|
||||
response = self.json_response(
|
||||
f"{self.base_url}/transfer/directdl",
|
||||
method="post",
|
||||
data={"apikey": self.api_key, "src": magnet}
|
||||
)
|
||||
|
||||
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 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 stream_link:
|
||||
logger.info(f"Found stream link: {stream_link[:50]}...")
|
||||
logger.info(f"Found matching episode stream link: {stream_link[:50]}...")
|
||||
return stream_link
|
||||
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
|
||||
|
||||
# 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"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Exception in get_stream_link: {str(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")
|
||||
return None
|
||||
finally:
|
||||
elapsed = time.time() - start_time
|
||||
logger.debug(f"get_stream_link completed in {elapsed:.1f}s")
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import re
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import time
|
||||
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, session: aiohttp.ClientSession = None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
self.base_url = f"{settings.rd_base_url}/{settings.rd_api_version}/"
|
||||
if not settings.rd_unique_account:
|
||||
self.token_manager = RDTokenManager(config)
|
||||
|
|
@ -47,57 +47,42 @@ class RealDebrid(BaseDebrid):
|
|||
else:
|
||||
return {"Authorization": f"Bearer {self.token_manager.get_access_token()}"}
|
||||
|
||||
async def add_magnet(self, magnet, ip=None):
|
||||
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}")
|
||||
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
|
||||
return self.json_response(
|
||||
url, method="post", headers=self.get_headers(), data=data
|
||||
)
|
||||
|
||||
async def add_torrent(self, torrent_file):
|
||||
def add_torrent(self, torrent_file):
|
||||
url = f"{self.base_url}torrents/addTorrent"
|
||||
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
|
||||
return self.json_response(
|
||||
url, method="put", headers=self.get_headers(), data=torrent_file
|
||||
)
|
||||
|
||||
async def delete_torrent(self, id):
|
||||
def delete_torrent(self, id):
|
||||
url = f"{self.base_url}torrents/delete/{id}"
|
||||
return await self.json_response(url, method="delete", headers=self.get_headers())
|
||||
return self.json_response(url, method="delete", headers=self.get_headers())
|
||||
|
||||
async def get_torrent_info(self, torrent_id):
|
||||
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 = await self.json_response(url, headers=self.get_headers())
|
||||
torrent_info = self.json_response(url, headers=self.get_headers())
|
||||
if not torrent_info or "files" not in torrent_info:
|
||||
return None
|
||||
return torrent_info
|
||||
|
||||
async def select_files(self, torrent_id, file_id):
|
||||
def select_files(self, torrent_id, file_id):
|
||||
logger.info(
|
||||
f"Real-Debrid: Selecting file(s): {file_id} for torrent ID: {torrent_id}"
|
||||
)
|
||||
await self._torrent_rate_limit()
|
||||
self._torrent_rate_limit()
|
||||
url = f"{self.base_url}torrents/selectFiles/{torrent_id}"
|
||||
data = {"files": str(file_id)}
|
||||
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
|
||||
requests.post(url, headers=self.get_headers(), data=data)
|
||||
|
||||
async def unrestrict_link(self, link):
|
||||
def unrestrict_link(self, link):
|
||||
url = f"{self.base_url}unrestrict/link"
|
||||
data = {"link": link}
|
||||
max_retries = 3
|
||||
|
|
@ -105,53 +90,52 @@ class RealDebrid(BaseDebrid):
|
|||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = await self.json_response(url, method="post", headers=self.get_headers(), data=data)
|
||||
response = 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 Exception as e:
|
||||
except requests.RequestException as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"Real-Debrid: Error unrestricting link (attempt {attempt + 1}/{max_retries}): {str(e)}")
|
||||
await asyncio.sleep(retry_delay)
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
logger.error(f"Real-Debrid: Failed to unrestrict link after {max_retries} attempts: {str(e)}")
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
async def is_already_added(self, magnet):
|
||||
def is_already_added(self, magnet):
|
||||
hash = magnet.split("urn:btih:")[1].split("&")[0].lower()
|
||||
url = f"{self.base_url}torrents"
|
||||
torrents = await self.json_response(url, headers=self.get_headers())
|
||||
torrents = self.json_response(url, headers=self.get_headers())
|
||||
for torrent in torrents:
|
||||
if torrent["hash"].lower() == hash:
|
||||
return torrent["id"]
|
||||
return False
|
||||
|
||||
async def wait_for_link(self, torrent_id, timeout=60, interval=5):
|
||||
import time
|
||||
def wait_for_link(self, torrent_id, timeout=60, interval=5):
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
torrent_info = await self.get_torrent_info(torrent_id)
|
||||
torrent_info = self.get_torrent_info(torrent_id)
|
||||
if (
|
||||
torrent_info
|
||||
and "links" in torrent_info
|
||||
and len(torrent_info["links"]) > 0
|
||||
):
|
||||
return torrent_info["links"]
|
||||
await asyncio.sleep(interval)
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
await self._torrent_rate_limit()
|
||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
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 await self.json_response(url, headers=self.get_headers())
|
||||
return self.json_response(url, headers=self.get_headers())
|
||||
|
||||
async def get_stream_link(self, query, config=None, ip=None):
|
||||
def get_stream_link(self, query, config, ip=None):
|
||||
# Extract query parameters
|
||||
magnet = query["magnet"]
|
||||
stream_type = query["type"]
|
||||
|
|
@ -163,42 +147,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 = await self._get_cached_torrent_ids(info_hash)
|
||||
cached_torrent_ids = 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 = await self._get_cached_torrent_info(cached_torrent_ids, file_index, season, episode, stream_type)
|
||||
torrent_info = 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 = await self.add_magnet_or_torrent_and_select(query, ip)
|
||||
torrent_id = 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 = await self.wait_for_link(torrent_id, timeout=20)
|
||||
links = self.wait_for_link(torrent_id, timeout=20) # Increased timeout to allow for slow servers
|
||||
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 = await self.get_torrent_info(torrent_id)
|
||||
torrent_info = self.get_torrent_info(torrent_id)
|
||||
|
||||
# Select the appropriate link
|
||||
if len(links) > 1:
|
||||
logger.info("Real-Debrid: Finding appropriate link")
|
||||
download_link = await self._find_appropriate_link(torrent_info, links, file_index, season, episode)
|
||||
download_link = 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 = await self.unrestrict_link(download_link)
|
||||
unrestrict_response = 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
|
||||
|
|
@ -206,10 +190,10 @@ class RealDebrid(BaseDebrid):
|
|||
logger.info(f"Real-Debrid: Got download link: {unrestrict_response['download']}")
|
||||
return unrestrict_response["download"]
|
||||
|
||||
async def _get_cached_torrent_ids(self, info_hash):
|
||||
await self._torrent_rate_limit()
|
||||
def _get_cached_torrent_ids(self, info_hash):
|
||||
self._torrent_rate_limit()
|
||||
url = f"{self.base_url}torrents"
|
||||
torrents = await self.json_response(url, headers=self.get_headers())
|
||||
torrents = self.json_response(url, headers=self.get_headers())
|
||||
|
||||
logger.info(f"Real-Debrid: Searching user's downloads for hash: {info_hash}")
|
||||
torrent_ids = [
|
||||
|
|
@ -219,11 +203,11 @@ class RealDebrid(BaseDebrid):
|
|||
]
|
||||
return torrent_ids
|
||||
|
||||
async def _get_cached_torrent_info(
|
||||
def _get_cached_torrent_info(
|
||||
self, cached_ids, file_index, season, episode, stream_type
|
||||
):
|
||||
for cached_torrent_id in cached_ids:
|
||||
cached_torrent_info = await self.get_torrent_info(cached_torrent_id)
|
||||
cached_torrent_info = self.get_torrent_info(cached_torrent_id)
|
||||
if self._torrent_contains_file(
|
||||
cached_torrent_info, file_index, season, episode, stream_type
|
||||
):
|
||||
|
|
@ -252,37 +236,37 @@ class RealDebrid(BaseDebrid):
|
|||
)
|
||||
return False
|
||||
|
||||
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
||||
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 = await self.add_magnet(magnet)
|
||||
magnet_response = 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=451, detail="Real-Debrid: Torrent banned or unavailable for legal reasons."
|
||||
status_code=500, detail="Real-Debrid: Failed to add magnet."
|
||||
)
|
||||
|
||||
torrent_id = magnet_response["id"]
|
||||
else:
|
||||
logger.info("Real-Debrid: Downloading and adding torrent file")
|
||||
torrent_file = await self.download_torrent_file(torrent_download)
|
||||
upload_response = await self.add_torrent(torrent_file)
|
||||
torrent_file = self.download_torrent_file(torrent_download)
|
||||
upload_response = 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=451, detail="Real-Debrid: Torrent banned or unavailable for legal reasons."
|
||||
status_code=500, detail="Real-Debrid: Failed to add torrent file."
|
||||
)
|
||||
|
||||
torrent_id = upload_response["id"]
|
||||
|
||||
logger.info(f"Real-Debrid: New torrent added with ID: {torrent_id}")
|
||||
return await self.get_torrent_info(torrent_id)
|
||||
|
||||
async def add_magnet_or_torrent_and_select(self, query, ip=None):
|
||||
return self.get_torrent_info(torrent_id)
|
||||
|
||||
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']
|
||||
|
|
@ -290,7 +274,7 @@ class RealDebrid(BaseDebrid):
|
|||
season = query["season"]
|
||||
episode = query["episode"]
|
||||
|
||||
torrent_info = await self.add_magnet_or_torrent(magnet, torrent_download, ip)
|
||||
torrent_info = 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
|
||||
|
|
@ -299,17 +283,17 @@ class RealDebrid(BaseDebrid):
|
|||
|
||||
if is_season_pack:
|
||||
logger.info("Real-Debrid: Processing season pack")
|
||||
await self._process_season_pack(torrent_info)
|
||||
self._process_season_pack(torrent_info)
|
||||
else:
|
||||
logger.info("Real-Debrid: Selecting specific file")
|
||||
await self._select_file(
|
||||
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']
|
||||
|
||||
async def _process_season_pack(self, torrent_info):
|
||||
def _process_season_pack(self, torrent_info):
|
||||
logger.info("Real-Debrid: Processing season pack files")
|
||||
video_file_indexes = [
|
||||
str(file["id"])
|
||||
|
|
@ -318,26 +302,26 @@ class RealDebrid(BaseDebrid):
|
|||
]
|
||||
|
||||
if video_file_indexes:
|
||||
await self.select_files(torrent_info["id"], ",".join(video_file_indexes))
|
||||
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"
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
time.sleep(10)
|
||||
else:
|
||||
logger.warning("Real-Debrid: No video files found in the season pack")
|
||||
|
||||
async def _select_file(self, torrent_info, stream_type, file_index, season, episode):
|
||||
|
||||
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}")
|
||||
await self.select_files(torrent_id, file_index)
|
||||
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}")
|
||||
await self.select_files(torrent_id, largest_file_id)
|
||||
self.select_files(torrent_id, largest_file_id)
|
||||
elif stream_type == "series":
|
||||
matching_files = [
|
||||
file
|
||||
|
|
@ -349,17 +333,17 @@ class RealDebrid(BaseDebrid):
|
|||
logger.info(
|
||||
f"Real-Debrid: Selecting largest matching file_index: {largest_file_id}"
|
||||
)
|
||||
await self.select_files(torrent_id, largest_file_id)
|
||||
self.select_files(torrent_id, largest_file_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"Real-Debrid: No matching files found for the specified episode"
|
||||
)
|
||||
|
||||
async def _find_appropriate_link(self, torrent_info, links, file_index, season, episode):
|
||||
def _find_appropriate_link(self, torrent_info, links, file_index, season, episode):
|
||||
# Refresh torrent info to get the latest selected files
|
||||
torrent_info = await self.get_torrent_info(torrent_info["id"])
|
||||
torrent_info = 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:
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,28 +1,31 @@
|
|||
import asyncio
|
||||
import aiohttp
|
||||
import json as json_lib
|
||||
from urllib.parse import quote, unquote
|
||||
import requests
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
import json
|
||||
|
||||
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, smart_episode_fallback, is_video_file
|
||||
from stream_fusion.utils.general import season_episode_in_filename
|
||||
|
||||
|
||||
class StremThru(BaseDebrid):
|
||||
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
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._headers = {}
|
||||
|
||||
self.session = self._create_session()
|
||||
|
||||
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 = [
|
||||
|
|
@ -35,33 +38,35 @@ class StremThru(BaseDebrid):
|
|||
("offcloud", "OCCredentials"),
|
||||
("pikpak", "PPCredentials")
|
||||
]
|
||||
|
||||
|
||||
for store_name, token_key in priority_order:
|
||||
token = self.config.get(token_key)
|
||||
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 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 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._headers = {
|
||||
"X-StremThru-Store-Name": store_name,
|
||||
"X-StremThru-Store-Authorization": f"Bearer {token}",
|
||||
"User-Agent": "stream-fusion"
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@staticmethod
|
||||
def get_underlying_debrid_code(store_name=None):
|
||||
"""Retourne le code du service de debrid sous-jacent (RD, AD, TB, PM, etc.)"""
|
||||
"""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é
|
||||
"""
|
||||
debrid_codes = {
|
||||
"realdebrid": "RD",
|
||||
"alldebrid": "AD",
|
||||
|
|
@ -72,8 +77,9 @@ 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:
|
||||
|
|
@ -85,25 +91,20 @@ class StremThru(BaseDebrid):
|
|||
"""Vérifie si l'utilisateur a un compte premium"""
|
||||
try:
|
||||
client_ip_param = f"&client_ip={ip}" if ip else ""
|
||||
response = await self.json_response(
|
||||
f"{self.base_url}/user?{client_ip_param}",
|
||||
headers=self._headers
|
||||
)
|
||||
response = self.json_response(f"{self.base_url}/user?{client_ip_param}")
|
||||
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
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
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]
|
||||
|
|
@ -118,292 +119,247 @@ 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}")
|
||||
|
||||
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}")
|
||||
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Erreur lors de la vérification des magnets sur StremThru-{self.store_name}: {e}")
|
||||
|
||||
|
||||
return results
|
||||
|
||||
async def add_magnet(self, magnet, ip=None, torrent_file_content=None):
|
||||
"""Ajoute un magnet à StremThru"""
|
||||
|
||||
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
|
||||
"""
|
||||
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}"
|
||||
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}")
|
||||
|
||||
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}")
|
||||
else:
|
||||
# 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
|
||||
logger.error(f"Erreur lors de l'ajout du magnet: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Erreur lors de l'ajout du magnet sur StremThru-{self.store_name}: {e}")
|
||||
|
||||
|
||||
return None
|
||||
|
||||
async def get_magnet_info(self, magnet_info, ip=None):
|
||||
"""Récupère les informations d'un magnet"""
|
||||
|
||||
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
|
||||
"""
|
||||
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}")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
async def get_stream_link(self, query, config=None, ip=None):
|
||||
"""Génère un lien de streaming à partir d'une requête"""
|
||||
|
||||
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
|
||||
"""
|
||||
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}"
|
||||
|
||||
|
||||
# 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}")
|
||||
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")
|
||||
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')})")
|
||||
|
|
@ -414,144 +370,75 @@ 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:
|
||||
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]}...")
|
||||
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]}...")
|
||||
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
|
||||
|
||||
async def start_background_caching(self, magnet, query=None):
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
result = self.add_magnet(magnet)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from itertools import islice
|
||||
import uuid
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import tenacity
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import HTTPException
|
||||
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
|
||||
|
|
@ -9,10 +9,9 @@ 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, session: aiohttp.ClientSession = None):
|
||||
super().__init__(config, session)
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
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}")
|
||||
|
|
@ -27,27 +26,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="TorBox token is not provided.")
|
||||
raise HTTPException(status_code=500, detail="AllDebrid token is not provided.")
|
||||
else:
|
||||
return {"Authorization": f"Bearer {self.config['TBToken']}"}
|
||||
return {"Authorization": f"Bearer {self.config["TBToken"]}"}
|
||||
|
||||
async def add_magnet(self, magnet, ip=None, privacy="private"):
|
||||
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 = 3
|
||||
seed = 2 if privacy == "private" else 1
|
||||
data = {
|
||||
"magnet": magnet,
|
||||
"seed": seed,
|
||||
"allow_zip": "false"
|
||||
}
|
||||
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data, retry_on_429=False)
|
||||
response = self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||
logger.info(f"Torbox: Add magnet response: {response}")
|
||||
return response
|
||||
|
||||
async def add_torrent(self, torrent_file, privacy="private"):
|
||||
def add_torrent(self, torrent_file, privacy="private"):
|
||||
logger.info("Torbox: Adding torrent file")
|
||||
url = f"{self.base_url}/torrents/createtorrent"
|
||||
seed = 3
|
||||
seed = 2 if privacy == "private" else 1
|
||||
data = {
|
||||
"seed": seed,
|
||||
"allow_zip": "false"
|
||||
|
|
@ -55,83 +54,73 @@ class Torbox(BaseDebrid):
|
|||
files = {
|
||||
"file": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')
|
||||
}
|
||||
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data, files=files, retry_on_429=False)
|
||||
response = self.json_response(url, method='post', headers=self.get_headers(), data=data, files=files)
|
||||
logger.info(f"Torbox: Add torrent file response: {response}")
|
||||
return response
|
||||
|
||||
async def get_torrent_info(self, torrent_id):
|
||||
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 = await self.json_response(url, headers=self.get_headers())
|
||||
response = self.json_response(url, headers=self.get_headers())
|
||||
logger.debug(f"Torbox: Torrent info response: {response}")
|
||||
return response
|
||||
|
||||
async def control_torrent(self, torrent_id, operation):
|
||||
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 = await self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||
response = self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||
logger.info(f"Torbox: Control torrent response: {response}")
|
||||
return response
|
||||
|
||||
async def request_download_link(self, torrent_id, file_id=None, zip_link=False):
|
||||
"""Request download link with retry logic"""
|
||||
@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):
|
||||
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
|
||||
|
||||
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):
|
||||
def get_stream_link(self, query, config, 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 = query["torrent_download"]
|
||||
if torrent_download:
|
||||
from urllib.parse import unquote
|
||||
torrent_download = unquote(torrent_download)
|
||||
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
|
||||
|
||||
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 = await self._find_existing_torrent(info_hash)
|
||||
|
||||
existing_torrent = self._find_existing_torrent(info_hash)
|
||||
|
||||
if existing_torrent:
|
||||
logger.info(f"Torbox: Found existing torrent with ID: {existing_torrent['id']}")
|
||||
torrent_id = existing_torrent["id"]
|
||||
# Get full torrent info with files
|
||||
torrent_response = await self.get_torrent_info(torrent_id)
|
||||
torrent_response = 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"]
|
||||
else:
|
||||
# Add the magnet or torrent file
|
||||
add_response = await self.add_magnet_or_torrent(magnet, torrent_download)
|
||||
add_response = 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 = add_response["torrent_id"]
|
||||
# Get full torrent info with files
|
||||
torrent_response = await self.get_torrent_info(torrent_id)
|
||||
torrent_response = 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
|
||||
|
|
@ -140,20 +129,20 @@ class Torbox(BaseDebrid):
|
|||
logger.info(f"Torbox: Working with torrent ID: {torrent_id}")
|
||||
|
||||
# Wait for the torrent to be ready
|
||||
if not await self._wait_for_torrent_completion(torrent_id):
|
||||
if not 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 is None:
|
||||
|
||||
if file_id == None:
|
||||
logger.error("Torbox: No matching file found.")
|
||||
return settings.no_cache_video_url
|
||||
|
||||
# Request the download link
|
||||
download_link_response = await self.request_download_link(torrent_id, file_id)
|
||||
|
||||
download_link_response = 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
|
||||
|
|
@ -161,23 +150,24 @@ class Torbox(BaseDebrid):
|
|||
logger.info(f"Torbox: Got download link: {download_link_response['data']}")
|
||||
return download_link_response['data']
|
||||
|
||||
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||
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"
|
||||
response = await self.json_response(url, headers=self.get_headers())
|
||||
|
||||
logger.trace(f"Torbox: Requesting URL: {url}")
|
||||
response = 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 availability for batch {i//50 + 1}")
|
||||
logger.debug(f"Torbox: No cached avaibility 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,
|
||||
|
|
@ -185,9 +175,9 @@ class Torbox(BaseDebrid):
|
|||
"data": all_results
|
||||
}
|
||||
|
||||
async def _find_existing_torrent(self, info_hash):
|
||||
def _find_existing_torrent(self, info_hash):
|
||||
logger.info(f"Torbox: Searching for existing torrent with hash: {info_hash}")
|
||||
torrents = await self.json_response(f"{self.base_url}/torrents/mylist", headers=self.get_headers())
|
||||
torrents = 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():
|
||||
|
|
@ -196,10 +186,14 @@ class Torbox(BaseDebrid):
|
|||
logger.info("Torbox: No existing torrent found")
|
||||
return None
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
logger.info(f"Torbox: Add torrent response: {response}")
|
||||
|
||||
|
|
@ -209,18 +203,17 @@ class Torbox(BaseDebrid):
|
|||
|
||||
return response["data"]
|
||||
|
||||
async def _wait_for_torrent_completion(self, torrent_id, timeout=60, interval=10):
|
||||
def _wait_for_torrent_completion(self, torrent_id, timeout=60, interval=10):
|
||||
logger.info(f"Torbox: Waiting for torrent completion, ID: {torrent_id}")
|
||||
|
||||
async def check_status():
|
||||
torrent_info = await self.get_torrent_info(torrent_id)
|
||||
def check_status():
|
||||
torrent_info = 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 = await self.wait_for_ready_status(check_status, timeout, interval)
|
||||
result = self.wait_for_ready_status(check_status, timeout, interval)
|
||||
if result:
|
||||
logger.info("Torbox: Torrent is ready")
|
||||
else:
|
||||
|
|
@ -230,7 +223,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")
|
||||
|
|
@ -238,11 +231,12 @@ 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", ""))
|
||||
|
|
@ -250,23 +244,25 @@ class Torbox(BaseDebrid):
|
|||
except (ValueError, TypeError):
|
||||
logger.error(f"Torbox: Invalid season/episode format: {season}/{episode}")
|
||||
return None
|
||||
|
||||
logger.info(f"Torbox: DEBUG - Processing {len(files)} files total")
|
||||
|
||||
logger.info(f"Torbox: DEBUG - Traitement de {len(files)} fichiers au 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'])})")
|
||||
|
||||
logger.info(f"Torbox: DEBUG - Fichier {i+1}: {file['short_name']} (taille: {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']}")
|
||||
logger.info(f"Torbox: Vérification fichier vidéo: {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']}")
|
||||
logger.info(f"Torbox: ✓ Match RTN pour {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}")
|
||||
|
||||
logger.info(f"Torbox: ✗ Pas de match RTN pour {file['short_name']}")
|
||||
else:
|
||||
logger.info(f"Torbox: Fichier ignoré (pas vidéo): {file['short_name']}")
|
||||
|
||||
logger.info(f"Torbox: {len(matching_files)} fichiers trouvés avec RTN pour 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['short_name']}, Size: {largest_matching_file['size']}) for series")
|
||||
|
|
@ -274,32 +270,37 @@ class Torbox(BaseDebrid):
|
|||
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")
|
||||
|
||||
|
||||
logger.info(f"Torbox: Appel du smart fallback avec {len(fallback_files)} fichiers")
|
||||
for f in fallback_files:
|
||||
logger.info(f"Torbox: Fichier fallback: {f.get('name')} (taille: {f.get('size')}, ID: {f.get('index')})")
|
||||
|
||||
fallback_file = smart_episode_fallback(fallback_files, numeric_season, numeric_episode)
|
||||
if fallback_file:
|
||||
logger.info(f"Torbox: Smart fallback selected: {fallback_file.get('name')} (ID: {fallback_file.get('index')})")
|
||||
logger.info(f"Torbox: Smart fallback a sélectionné: {fallback_file.get('name')} (ID: {fallback_file.get('index')})")
|
||||
return fallback_file.get('index')
|
||||
else:
|
||||
logger.info("Torbox: Smart fallback found nothing, trying final fallback for single file")
|
||||
logger.info("Torbox: Smart fallback n'a rien trouvé, essai du fallback final pour fichier unique")
|
||||
# Final fallback: for single-file torrents, just take the only video 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")
|
||||
|
||||
logger.info(f"Torbox: DEBUG - Trouvé {len(video_files)} fichiers vidéo dans le fallback final")
|
||||
for vf in video_files:
|
||||
logger.info(f"Torbox: DEBUG - Fichier vidéo: {vf['short_name']} (ID: {vf['id']})")
|
||||
|
||||
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']})")
|
||||
logger.info(f"Torbox: Fichier vidéo unique détecté, utilisation: {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")
|
||||
logger.error(f"Torbox: Le fallback intelligent a aussi échoué pour S{numeric_season:02d}E{numeric_episode:02d}")
|
||||
logger.error(f"Torbox: Trouvé {len(video_files)} fichiers vidéo, attendu exactement 1 pour le fallback fichier unique")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -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", "MULTI"],
|
||||
2: ["VFF", "VOF", "VFI", "FRENCH"],
|
||||
# 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", "MULTI"],
|
||||
1: ["VFF", "VOF", "VFI"],
|
||||
# Groupe 2 (priorité moyenne)
|
||||
2: ["VF2", "VFQ", "VQ", "FRENCH"],
|
||||
# Groupe 3 (priorité basse)
|
||||
|
|
|
|||
|
|
@ -13,78 +13,26 @@ 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}")
|
||||
# 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'):
|
||||
if not 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=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)))
|
||||
sorted_items = sorted(items, key=sort_quality)
|
||||
elif config["sort"] == "sizeasc":
|
||||
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)))
|
||||
sorted_items = sorted(items, key=lambda x: int(x.size))
|
||||
elif config["sort"] == "sizedesc":
|
||||
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)))
|
||||
sorted_items = sorted(items, key=lambda x: int(x.size), reverse=True)
|
||||
elif config["sort"] == "qualitythensize":
|
||||
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)))
|
||||
sorted_items = sorted(items, key=lambda x: (sort_quality(x), -int(x.size)))
|
||||
else:
|
||||
logger.warning(
|
||||
f"Filters: Unrecognized sort method: {config['sort']}. No sorting applied."
|
||||
|
|
@ -92,7 +40,7 @@ def items_sort(items, config):
|
|||
sorted_items = items
|
||||
|
||||
logger.success(
|
||||
f"Filters: Sorting complete - Quality/Size first, YggFlix priority at equal quality, seeders as tiebreaker. Number of sorted items: {len(sorted_items)}"
|
||||
f"Filters: Sorting complete. Number of sorted items: {len(sorted_items)}"
|
||||
)
|
||||
return sorted_items
|
||||
|
||||
|
|
@ -131,11 +79,6 @@ 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(
|
||||
|
|
@ -211,21 +154,10 @@ def remove_non_matching_title(items, titles):
|
|||
return subset_index == len(subset_words)
|
||||
|
||||
for item in items:
|
||||
# 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()
|
||||
|
||||
cleaned_item_title = integrale_pattern.sub(
|
||||
"", item.parsed_data.parsed_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)
|
||||
|
|
@ -265,20 +197,16 @@ def remove_non_matching_title(items, titles):
|
|||
return filtered_items
|
||||
|
||||
|
||||
def filter_items(items, media, config, skip_resolution=False):
|
||||
def filter_items(items, media, config):
|
||||
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)}")
|
||||
|
|
@ -357,15 +285,26 @@ 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, item.privacy)
|
||||
key = (item.raw_title, item.size)
|
||||
if key not in merged_dict:
|
||||
merged_dict[key] = item
|
||||
else:
|
||||
existing_priority = get_indexer_priority_for_sort(merged_dict[key].indexer)
|
||||
new_priority = get_indexer_priority_for_sort(item.indexer)
|
||||
|
||||
if new_priority < existing_priority or (new_priority == existing_priority and (item.seeders or 0) > (merged_dict[key].seeders or 0)):
|
||||
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):
|
||||
merged_dict[key] = item
|
||||
|
||||
for item in cache_items:
|
||||
|
|
|
|||
|
|
@ -28,45 +28,36 @@ 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
|
||||
|
||||
# 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())
|
||||
|
||||
sorted_files = sorted(video_files, key=lambda f: f.get("name", "").lower())
|
||||
if episode <= len(sorted_files):
|
||||
selected_file = sorted_files[episode - 1] # Index 0-based
|
||||
logger.info(f"Smart fallback: Sélection par ordre alphabétique (épisode #{episode}): {selected_file.get('name')}")
|
||||
logger.debug(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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import os
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
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
|
||||
|
|
@ -16,83 +19,77 @@ from stream_fusion.settings import settings
|
|||
|
||||
|
||||
class JackettService:
|
||||
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
|
||||
def __init__(self, config):
|
||||
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)
|
||||
|
||||
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]:
|
||||
def search(self, media):
|
||||
self.logger.info("Started Jackett search for " + media.type + " " + media.titles[0])
|
||||
|
||||
indexers = await self.__get_indexers()
|
||||
indexers = self.__get_indexers()
|
||||
threads = []
|
||||
results_queue = queue.Queue() # Create a Queue instance to hold the results
|
||||
|
||||
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!")
|
||||
# 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()
|
||||
|
||||
# Lancer toutes les recherches en parallèle avec asyncio.gather()
|
||||
import time
|
||||
tasks = []
|
||||
for indexer in indexers:
|
||||
tasks.append(self.__search_indexer_wrapper(media, indexer, search_func))
|
||||
# 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!")
|
||||
|
||||
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)
|
||||
|
||||
return self.__post_process_results(results, media)
|
||||
|
||||
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()
|
||||
|
||||
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 []
|
||||
f"Search on {indexer.title} took {time.time() - start_time} seconds and found {len([result for sublist in result for result in sublist])} results")
|
||||
|
||||
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
|
||||
)
|
||||
results_queue.put(result) # Put the result in the queue
|
||||
|
||||
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)))
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
results = []
|
||||
|
||||
# Retrieve results from the queue and append them to the results list
|
||||
while not results_queue.empty():
|
||||
results.extend(results_queue.get())
|
||||
|
||||
flatten_results = [result for sublist in results for result in sublist]
|
||||
|
||||
return self.__post_process_results(flatten_results, media)
|
||||
|
||||
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)
|
||||
|
||||
if has_imdb_search_capability:
|
||||
languages = ['en']
|
||||
|
|
@ -102,15 +99,12 @@ 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 = {
|
||||
|
|
@ -128,28 +122,23 @@ class JackettService:
|
|||
url += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
||||
|
||||
try:
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
text = await response.text()
|
||||
results.append(self.__get_torrent_links_from_xml(text))
|
||||
response = self.__session.get(url)
|
||||
response.raise_for_status()
|
||||
results.append(self.__get_torrent_links_from_xml(response.text))
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
f"An exception occurred while searching for a movie on Jackett with indexer {indexer.title} and "
|
||||
f"language {lang}."
|
||||
)
|
||||
f"An exception occured while searching for a movie on Jackett with indexer {indexer.title} and "
|
||||
f"language {lang}.")
|
||||
|
||||
return results
|
||||
|
||||
async def __search_series_indexer(self, series: Series, indexer: JackettIndexer) -> List[List[JackettResult]]:
|
||||
def __search_series_indexer(self, series, indexer):
|
||||
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]
|
||||
|
|
@ -158,15 +147,12 @@ 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 = {
|
||||
|
|
@ -183,24 +169,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 = {**params, 'season': season}
|
||||
url_season += '?' + '&'.join([f'{k}={v}' for k, v in params_season.items()])
|
||||
params['season'] = season
|
||||
url_season += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
||||
|
||||
url_ep = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
|
||||
params_ep = {**params_season, 'ep': episode}
|
||||
url_ep += '?' + '&'.join([f'{k}={v}' for k, v in params_ep.items()])
|
||||
params['ep'] = episode
|
||||
url_ep += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
||||
|
||||
try:
|
||||
# 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)
|
||||
# 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()
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
if data_ep:
|
||||
results.append(data_ep)
|
||||
|
|
@ -208,33 +194,29 @@ class JackettService:
|
|||
results.append(data_season)
|
||||
|
||||
if not data_ep and not data_season:
|
||||
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)
|
||||
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)
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
f"An exception occurred while searching for a series on Jackett with indexer {indexer.title} and language {lang}."
|
||||
)
|
||||
f"An exception occured while searching for a series on Jackett with indexer {indexer.title} and language {lang}.")
|
||||
|
||||
return results
|
||||
|
||||
async def __get_indexers(self) -> List[JackettIndexer]:
|
||||
def __get_indexers(self):
|
||||
url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=indexers&configured=true"
|
||||
|
||||
session = await self._get_session()
|
||||
try:
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
text = await response.text()
|
||||
return self.__get_indexer_from_xml(text)
|
||||
response = self.__session.get(url)
|
||||
response.raise_for_status()
|
||||
return self.__get_indexer_from_xml(response.text)
|
||||
except Exception:
|
||||
self.logger.exception("An exception occurred while getting indexers from Jackett.")
|
||||
self.logger.exception("An exception occured while getting indexers from Jackett.")
|
||||
return []
|
||||
|
||||
def __get_indexer_from_xml(self, xml_content: str) -> List[JackettIndexer]:
|
||||
def __get_indexer_from_xml(self, xml_content):
|
||||
xml_root = ET.fromstring(xml_content)
|
||||
|
||||
indexer_list = []
|
||||
|
|
@ -266,17 +248,15 @@ class JackettService:
|
|||
|
||||
return indexer_list
|
||||
|
||||
def __get_torrent_links_from_xml(self, xml_content: str) -> List[JackettResult]:
|
||||
def __get_torrent_links_from_xml(self, xml_content):
|
||||
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
|
||||
|
||||
|
|
@ -286,26 +266,24 @@ class JackettService:
|
|||
result.indexer = item.find('jackettindexer').text
|
||||
result.privacy = item.find('type').text
|
||||
|
||||
magnet = item.find(
|
||||
'.//torznab:attr[@name="magneturl"]',
|
||||
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}
|
||||
)
|
||||
# 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'})
|
||||
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: List[JackettResult], media) -> List[JackettResult]:
|
||||
def __post_process_results(self, results, media):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import os
|
||||
import aiohttp
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
|
||||
from stream_fusion.utils.models.movie import Movie
|
||||
|
|
@ -8,81 +6,28 @@ from stream_fusion.utils.models.series import Series
|
|||
|
||||
|
||||
class Cinemeta(MetadataProvider):
|
||||
# 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):
|
||||
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]
|
||||
|
||||
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}")
|
||||
url = f"https://v3-cinemeta.strem.io/meta/{type}/{full_id[0]}.json"
|
||||
response = requests.get(url)
|
||||
data = response.json()
|
||||
|
||||
if type == "movie":
|
||||
result = Movie(
|
||||
id=id,
|
||||
tmdb_id=tmdb_id,
|
||||
titles=[title],
|
||||
year=data["meta"].get("year", 2024),
|
||||
tmdb_id=None,
|
||||
titles=[self.replace_weird_characters(data["meta"]["name"])],
|
||||
year=data["meta"]["year"],
|
||||
languages=["en"]
|
||||
)
|
||||
else:
|
||||
result = Series(
|
||||
id=id,
|
||||
tmdb_id=tmdb_id,
|
||||
titles=[title],
|
||||
tmdb_id=None,
|
||||
titles=[self.replace_weird_characters(data["meta"]["name"])],
|
||||
season="S{:02d}".format(int(full_id[1])),
|
||||
episode="E{:02d}".format(int(full_id[2])),
|
||||
languages=["en"]
|
||||
|
|
|
|||
|
|
@ -1,27 +1,11 @@
|
|||
import aiohttp
|
||||
from typing import Optional
|
||||
|
||||
from stream_fusion.logging_config import logger
|
||||
|
||||
|
||||
class MetadataProvider:
|
||||
|
||||
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
|
||||
def __init__(self, config):
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import asyncio
|
||||
import aiohttp
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
|
||||
from stream_fusion.utils.models.movie import Movie
|
||||
|
|
@ -8,30 +6,22 @@ from stream_fusion.utils.models.series import Series
|
|||
from stream_fusion.settings import settings
|
||||
from stream_fusion.logging_config import logger
|
||||
|
||||
|
||||
class TMDB(MetadataProvider):
|
||||
async def get_metadata(self, id, type):
|
||||
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}"
|
||||
|
||||
async with session.get(url) as response:
|
||||
data = await response.json()
|
||||
|
||||
response = requests.get(url)
|
||||
data = 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"],
|
||||
|
|
@ -40,10 +30,6 @@ 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])
|
||||
|
|
@ -58,11 +44,9 @@ class TMDB(MetadataProvider):
|
|||
)
|
||||
else:
|
||||
if type == "movie":
|
||||
if data.get("movie_results") and len(data["movie_results"]) > 0:
|
||||
result.titles.append(self.replace_weird_characters(data["movie_results"][0]["title"]))
|
||||
result.titles.append(self.replace_weird_characters(data["movie_results"][0]["title"]))
|
||||
else:
|
||||
if data.get("tv_results") and len(data["tv_results"]) > 0:
|
||||
result.titles.append(self.replace_weird_characters(data["tv_results"][0]["name"]))
|
||||
result.titles.append(self.replace_weird_characters(data["tv_results"][0]["name"]))
|
||||
|
||||
self.logger.info("Got metadata for " + type + " with id " + id)
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -20,16 +20,4 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
from typing import List, Dict
|
||||
|
||||
from RTN import ParsedData
|
||||
|
|
@ -25,73 +26,61 @@ class StreamParser:
|
|||
self.config = config
|
||||
self.configb64 = encodeb64(json.dumps(config).replace("=", "%3D"))
|
||||
|
||||
async def parse_to_stremio_streams(
|
||||
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 = []
|
||||
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)
|
||||
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())
|
||||
|
||||
if self.config["debrid"]:
|
||||
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)
|
||||
stream_list = sorted(stream_list, key=filter_by_availability)
|
||||
stream_list = sorted(stream_list, key=filter_by_direct_torrent)
|
||||
|
||||
return stream_list
|
||||
|
||||
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 _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 _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)
|
||||
|
|
@ -100,49 +89,20 @@ class StreamParser:
|
|||
json.dumps(torrent_item.to_debrid_stream_query(media))
|
||||
).replace("=", "%3D")
|
||||
|
||||
results = []
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 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":
|
||||
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}"
|
||||
self._add_direct_torrent_stream(torrent_item, parsed_data, title, results, media)
|
||||
|
||||
def _create_stream_name(
|
||||
self, torrent_item: TorrentItem, parsed_data: ParsedData
|
||||
|
|
@ -173,8 +133,10 @@ class StreamParser:
|
|||
def _create_stream_title(
|
||||
self, torrent_item: TorrentItem, parsed_data: ParsedData, media: Media
|
||||
) -> str:
|
||||
title = f"{torrent_item.file_name}\n" if torrent_item.file_name else f"{torrent_item.raw_title}\n"
|
||||
title = 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)
|
||||
|
|
@ -211,38 +173,33 @@ 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 _create_direct_torrent_stream(
|
||||
def _add_direct_torrent_stream(
|
||||
self,
|
||||
torrent_item: TorrentItem,
|
||||
parsed_data: ParsedData,
|
||||
title: str,
|
||||
results: queue.Queue,
|
||||
media: Media,
|
||||
) -> Dict:
|
||||
"""Crée un stream direct torrent"""
|
||||
) -> None:
|
||||
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)})"
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,75 +1,74 @@
|
|||
import asyncio
|
||||
import aiohttp
|
||||
from typing import Optional
|
||||
import time
|
||||
from urllib3.util.retry import Retry
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
from stream_fusion.settings import settings
|
||||
from stream_fusion.logging_config import logger
|
||||
|
||||
|
||||
class AsyncRateLimiter:
|
||||
"""Rate limiter async pour contrôler le débit des requêtes."""
|
||||
class RateLimiter:
|
||||
def __init__(self, calls_per_second=1):
|
||||
self.calls_per_second = calls_per_second
|
||||
self.last_call = 0
|
||||
|
||||
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
|
||||
def __call__(self, func):
|
||||
def wrapper(*args, **kwargs):
|
||||
now = time.time()
|
||||
time_since_last_call = now - self.last_call
|
||||
if time_since_last_call < self.min_interval:
|
||||
await asyncio.sleep(self.min_interval - time_since_last_call)
|
||||
if time_since_last_call < 1 / self.calls_per_second:
|
||||
time.sleep(1 / self.calls_per_second - time_since_last_call)
|
||||
self.last_call = time.time()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class SharewoodAPI:
|
||||
def __init__(
|
||||
self,
|
||||
sharewood_passkey: str,
|
||||
session: Optional[aiohttp.ClientSession] = None,
|
||||
timeout: int = 10,
|
||||
pool_connections=10,
|
||||
pool_maxsize=10,
|
||||
max_retries=3,
|
||||
timeout=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 = aiohttp.ClientTimeout(total=timeout)
|
||||
self.rate_limiter = AsyncRateLimiter(calls_per_second=1)
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.rate_limiter = RateLimiter(calls_per_second=1)
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
@RateLimiter()
|
||||
def _make_request(self, method, endpoint, params=None):
|
||||
url = f"{self.base_url}/{self.sharewood_passkey}/{endpoint}"
|
||||
session = await self._get_session()
|
||||
|
||||
try:
|
||||
async with session.request(method, url, params=params) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
response = self.session.request(
|
||||
method, url, params=params, timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"An error occurred during the request: {e}")
|
||||
raise
|
||||
|
||||
async def get_last_torrents(self, category: int = None, subcategory: int = None, limit: int = 25):
|
||||
def get_last_torrents(self, category=None, subcategory=None, limit=25):
|
||||
"""Get the last torrents, optionally filtered by category or subcategory."""
|
||||
params = {}
|
||||
if category:
|
||||
|
|
@ -78,56 +77,57 @@ class SharewoodAPI:
|
|||
params["subcategory"] = subcategory
|
||||
if limit and 1 <= limit <= 25:
|
||||
params["limit"] = limit
|
||||
return await self._make_request("GET", "last-torrents", params=params)
|
||||
return self._make_request("GET", "last-torrents", params=params)
|
||||
|
||||
async def search(self, query: str, category: int = None, subcategory: int = None):
|
||||
def search(self, query, category=None, subcategory=None):
|
||||
"""Search for torrents, optionally filtered by category or subcategory."""
|
||||
params = {"name": query}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if subcategory:
|
||||
params["subcategory"] = subcategory
|
||||
return await self._make_request("GET", "search", params=params)
|
||||
return self._make_request("GET", "search", params=params)
|
||||
|
||||
async def get_video_torrents(self, limit: int = 25):
|
||||
def get_video_torrents(self, limit=25):
|
||||
"""Get the last video torrents."""
|
||||
return await self.get_last_torrents(category=1, limit=limit)
|
||||
return self.get_last_torrents(category=1, limit=limit)
|
||||
|
||||
async def get_audio_torrents(self, limit: int = 25):
|
||||
def get_audio_torrents(self, limit=25):
|
||||
"""Get the last audio torrents."""
|
||||
return await self.get_last_torrents(category=2, limit=limit)
|
||||
return self.get_last_torrents(category=2, limit=limit)
|
||||
|
||||
async def get_application_torrents(self, limit: int = 25):
|
||||
def get_application_torrents(self, limit=25):
|
||||
"""Get the last application torrents."""
|
||||
return await self.get_last_torrents(category=3, limit=limit)
|
||||
return self.get_last_torrents(category=3, limit=limit)
|
||||
|
||||
async def get_ebook_torrents(self, limit: int = 25):
|
||||
def get_ebook_torrents(self, limit=25):
|
||||
"""Get the last ebook torrents."""
|
||||
return await self.get_last_torrents(category=4, limit=limit)
|
||||
return self.get_last_torrents(category=4, limit=limit)
|
||||
|
||||
async def get_game_torrents(self, limit: int = 25):
|
||||
def get_game_torrents(self, limit=25):
|
||||
"""Get the last game torrents."""
|
||||
return await self.get_last_torrents(category=5, limit=limit)
|
||||
return self.get_last_torrents(category=5, limit=limit)
|
||||
|
||||
async def get_training_torrents(self, limit: int = 25):
|
||||
def get_training_torrents(self, limit=25):
|
||||
"""Get the last training torrents."""
|
||||
return await self.get_last_torrents(category=6, limit=limit)
|
||||
return self.get_last_torrents(category=6, limit=limit)
|
||||
|
||||
async def get_adult_torrents(self, limit: int = 25):
|
||||
def get_adult_torrents(self, limit=25):
|
||||
"""Get the last adult torrents."""
|
||||
return await self.get_last_torrents(category=7, limit=limit)
|
||||
return self.get_last_torrents(category=7, limit=limit)
|
||||
|
||||
async def download_torrent(self, torrent_id: int) -> bytes:
|
||||
@RateLimiter()
|
||||
def download_torrent(self, torrent_id):
|
||||
"""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:
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
return await response.read()
|
||||
except aiohttp.ClientError as e:
|
||||
response = self.session.get(url, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except requests.exceptions.RequestException 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()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import re
|
||||
import urllib.parse
|
||||
import aiohttp
|
||||
from typing import List, Union, Optional
|
||||
from typing import List, Union
|
||||
from RTN import parse
|
||||
|
||||
from stream_fusion.logging_config import logger
|
||||
|
|
@ -14,17 +13,17 @@ from stream_fusion.utils.sharewood.sharewood_api import SharewoodAPI
|
|||
|
||||
|
||||
class SharewoodService:
|
||||
"""Service for searching media on Sharewood (async version)."""
|
||||
"""Service for searching media on Sharewood."""
|
||||
|
||||
def __init__(self, config: dict, session: Optional[aiohttp.ClientSession] = None):
|
||||
def __init__(self, config: dict):
|
||||
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, session=session)
|
||||
self.sharewood = SharewoodAPI(self.sharewood_passkey)
|
||||
|
||||
async def search(self, media: Union[Movie, Series]) -> List[SharewoodResult]:
|
||||
def search(self, media: Union[Movie, Series]) -> List[SharewoodResult]:
|
||||
"""
|
||||
Search for a media (movie or series) on sharewood.
|
||||
|
||||
|
|
@ -32,15 +31,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 = await self.__search_movie(media)
|
||||
results = self.__search_movie(media)
|
||||
elif isinstance(media, Series):
|
||||
results = await self.__search_series(media)
|
||||
results = self.__search_series(media)
|
||||
else:
|
||||
raise TypeError("Only Movie and Series types are allowed as media!")
|
||||
|
||||
|
|
@ -60,10 +59,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()
|
||||
|
|
@ -76,7 +75,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):
|
||||
|
|
@ -157,17 +156,15 @@ class SharewoodService:
|
|||
if not (title.lower() in seen or seen.add(title.lower()))
|
||||
]
|
||||
|
||||
async def __search_movie(self, movie: Movie) -> List[dict]:
|
||||
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:
|
||||
search_results = await self.sharewood.search(query=title, category=1)
|
||||
if search_results:
|
||||
results.extend(search_results)
|
||||
results.extend(self.sharewood.search(query=title, category=1))
|
||||
return self.__deduplicate_api_results(results)
|
||||
|
||||
async def __search_series(self, series: Series) -> List[dict]:
|
||||
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()
|
||||
|
|
@ -179,9 +176,7 @@ class SharewoodService:
|
|||
|
||||
results = []
|
||||
for text in search_texts:
|
||||
search_results = await self.sharewood.search(query=text, category=1)
|
||||
if search_results:
|
||||
results.extend(search_results)
|
||||
results.extend(self.sharewood.search(query=text, category=1))
|
||||
return self.__deduplicate_api_results(results)
|
||||
|
||||
def __filter_out_no_seeders(self, results: List[dict]) -> List[dict]:
|
||||
|
|
@ -202,7 +197,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 []
|
||||
|
|
@ -234,7 +229,3 @@ class SharewoodService:
|
|||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
async def close(self):
|
||||
"""Ferme les ressources."""
|
||||
await self.sharewood.close()
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
|
||||
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
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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
|
||||
|
|
@ -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, torrent_download=None, tmdb_id=None):
|
||||
privacy, type=None, parsed_data=None):
|
||||
self.logger = logger
|
||||
|
||||
self.raw_title = raw_title # Raw title of the torrent
|
||||
|
|
@ -22,20 +22,15 @@ 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 = 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.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.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:
|
||||
|
|
@ -45,23 +40,12 @@ 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 and not self.availability) else None,
|
||||
"torrent_download": quote(self.torrent_download) if self.torrent_download is not None 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,
|
||||
|
|
@ -73,7 +57,6 @@ 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,
|
||||
|
|
@ -81,7 +64,6 @@ class TorrentItem:
|
|||
'file_index': self.file_index,
|
||||
'full_index': self.full_index,
|
||||
'availability': self.availability,
|
||||
'parsed_data': parsed_data_dict,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -100,10 +82,9 @@ class TorrentItem:
|
|||
languages=data['languages'],
|
||||
indexer=data['indexer'],
|
||||
privacy=data['privacy'],
|
||||
type=data['type'],
|
||||
tmdb_id=data.get('tmdb_id')
|
||||
type=data['type']
|
||||
)
|
||||
|
||||
|
||||
instance.file_name = data['file_name']
|
||||
instance.files = data['files']
|
||||
instance.torrent_download = data['torrent_download']
|
||||
|
|
@ -111,28 +92,7 @@ class TorrentItem:
|
|||
instance.file_index = data['file_index']
|
||||
instance.full_index = data['full_index']
|
||||
instance.availability = data['availability']
|
||||
|
||||
# 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}'")
|
||||
|
||||
instance.parsed_data = parse(instance.raw_title)
|
||||
|
||||
return instance
|
||||
|
|
|
|||
|
|
@ -3,13 +3,10 @@ import os
|
|||
import time
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
import pathlib
|
||||
import json
|
||||
|
||||
import bencodepy
|
||||
import bencode
|
||||
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
|
||||
|
|
@ -22,15 +19,11 @@ 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:
|
||||
|
|
@ -43,7 +36,6 @@ 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:
|
||||
|
|
@ -52,57 +44,9 @@ 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)
|
||||
# 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)}")
|
||||
await self.torrent_dao.create_torrent_item(torrent_item, unique_id)
|
||||
|
||||
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.
|
||||
"""
|
||||
async def convert_and_process(self, results: List[JackettResult | ZileanResult | YggflixResult | SharewoodResult]):
|
||||
torrent_items_result = []
|
||||
|
||||
for result in results:
|
||||
|
|
@ -110,25 +54,9 @@ 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):
|
||||
|
|
@ -210,11 +138,11 @@ class TorrentService:
|
|||
|
||||
def __process_torrent(self, result: TorrentItem, torrent_file):
|
||||
try:
|
||||
metadata = bencodepy.decode(torrent_file)
|
||||
metadata = bencode.bdecode(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)}")
|
||||
|
|
@ -225,19 +153,6 @@ 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"])
|
||||
|
|
@ -255,16 +170,7 @@ class TorrentService:
|
|||
result.files = metadata["info"]["files"]
|
||||
|
||||
if result.type == "series":
|
||||
# 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}")
|
||||
file_details = self.__find_single_episode_file(result.files, result.parsed_data.seasons, result.parsed_data.episodes)
|
||||
|
||||
if file_details is not None:
|
||||
self.logger.debug("File details")
|
||||
|
|
@ -272,9 +178,8 @@ class TorrentService:
|
|||
result.file_index = file_details["file_index"]
|
||||
result.file_name = file_details["title"]
|
||||
result.size = file_details["size"]
|
||||
|
||||
# Always create full_index for series - AllDebrid needs all files to search for matching episode
|
||||
result.full_index = self.__find_full_index(result.files)
|
||||
else:
|
||||
result.full_index = self.__find_full_index(result.files)
|
||||
|
||||
if result.type == "movie":
|
||||
result.file_index = self.__find_movie_file(result.files)
|
||||
|
|
@ -293,7 +198,7 @@ class TorrentService:
|
|||
return result
|
||||
|
||||
def __convert_torrent_to_hash(self, torrent_contents):
|
||||
hashcontents = bencodepy.encode(torrent_contents)
|
||||
hashcontents = bencode.bencode(torrent_contents)
|
||||
hexHash = hashlib.sha1(hashcontents).hexdigest()
|
||||
return hexHash.lower()
|
||||
|
||||
|
|
|
|||
|
|
@ -75,37 +75,24 @@ class TorrentSmartContainer:
|
|||
self.logger.trace(
|
||||
"TorrentSmartContainer: Item added to best matching (has file index)"
|
||||
)
|
||||
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
|
||||
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"]
|
||||
best_matching.append(torrent_item)
|
||||
self.logger.trace(
|
||||
"TorrentSmartContainer: Item added to best matching (series without full_index, will be extracted by debrid)"
|
||||
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"
|
||||
)
|
||||
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)
|
||||
|
|
@ -113,16 +100,6 @@ 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"
|
||||
)
|
||||
|
|
@ -355,7 +332,7 @@ class TorrentSmartContainer:
|
|||
processed_files.append(
|
||||
{
|
||||
"file_index": index,
|
||||
"title": os.path.basename(file["name"]),
|
||||
"title": file["name"],
|
||||
"size": file["size"],
|
||||
}
|
||||
)
|
||||
|
|
@ -363,7 +340,7 @@ class TorrentSmartContainer:
|
|||
processed_files.append(
|
||||
{
|
||||
"file_index": index,
|
||||
"title": os.path.basename(file["name"]),
|
||||
"title": file["name"],
|
||||
"size": file["size"],
|
||||
}
|
||||
)
|
||||
|
|
@ -489,8 +466,7 @@ class TorrentSmartContainer:
|
|||
is_season_pack = item.type == "series" and len(video_files) > 5
|
||||
|
||||
if is_season_pack:
|
||||
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]}")
|
||||
self.logger.info(f"TorrentSmartContainer: Détection d'un pack de saison avec {len(video_files)} fichiers vidéo pour {item.raw_title}")
|
||||
|
||||
if item.type == "series":
|
||||
# Version originale simple : chercher les fichiers qui correspondent à l'épisode
|
||||
|
|
@ -515,11 +491,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, skip_file_name_for_series=False)
|
||||
self._update_file_details(item, matching_files, debrid=debrid_code)
|
||||
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 = {
|
||||
|
|
@ -527,7 +503,7 @@ class TorrentSmartContainer:
|
|||
"title": fallback_file.get("name", ""),
|
||||
"size": fallback_file.get("size", 0),
|
||||
}
|
||||
self._update_file_details(item, [file_info], debrid=debrid_code, skip_file_name_for_series=False)
|
||||
self._update_file_details(item, [file_info], debrid=debrid_code)
|
||||
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
|
||||
|
|
@ -560,7 +536,7 @@ class TorrentSmartContainer:
|
|||
"TorrentSmartContainer: StremThru availability update completed"
|
||||
)
|
||||
|
||||
def _update_file_details(self, torrent_item, files, debrid: str = "??", skip_file_name_for_series=False):
|
||||
def _update_file_details(self, torrent_item, files, debrid: str = "??"):
|
||||
if not files:
|
||||
self.logger.debug(
|
||||
f"TorrentSmartContainer: No files to update for {torrent_item.raw_title}"
|
||||
|
|
@ -571,11 +547,7 @@ class TorrentSmartContainer:
|
|||
# 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.file_name = file["title"]
|
||||
torrent_item.size = file["size"]
|
||||
self.logger.debug(
|
||||
f"TorrentSmartContainer: Updated file details for {torrent_item.raw_title}: {file['title']}"
|
||||
|
|
@ -592,17 +564,9 @@ class TorrentSmartContainer:
|
|||
self.logger.debug(f"Adding {item.info_hash} to items dict")
|
||||
items_dict[item.info_hash] = item
|
||||
else:
|
||||
# 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.debug(
|
||||
f"TorrentSmartContainer: Skipping duplicate info hash: {item.info_hash}"
|
||||
)
|
||||
self.logger.info(
|
||||
f"TorrentSmartContainer: Built dictionary with {len(items_dict)} unique items"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,27 @@ from stream_fusion.logging_config import logger
|
|||
|
||||
|
||||
class YggflixAPI:
|
||||
def __init__(self, pool_connections=10, pool_maxsize=50, max_retries=0, timeout=5):
|
||||
def __init__(
|
||||
self,
|
||||
pool_connections=10,
|
||||
pool_maxsize=50,
|
||||
max_retries=0,
|
||||
timeout=8,
|
||||
):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
self.base_url = f"{settings.yggflix_url}/api"
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
|
|
@ -19,14 +39,39 @@ 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:
|
||||
|
|
@ -43,38 +88,136 @@ 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]:
|
||||
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.
|
||||
"""
|
||||
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={"passkey": passkey}, timeout=self.timeout)
|
||||
response = self.session.get(url, params=params, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except requests.exceptions.HTTPError as e:
|
||||
|
|
@ -85,4 +228,10 @@ 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()
|
||||
|
|
|
|||
|
|
@ -7,35 +7,32 @@ from stream_fusion.utils.detection import detect_languages
|
|||
|
||||
class YggflixResult:
|
||||
def __init__(self):
|
||||
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.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.from_cache = None
|
||||
self.languages = None
|
||||
self.type = None
|
||||
self.tmdb_id = None
|
||||
self.parsed_data = None
|
||||
|
||||
self.languages = None # Language of the torrent
|
||||
self.type = None # series or movie
|
||||
|
||||
self.parsed_data = None # Ranked result
|
||||
|
||||
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(
|
||||
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
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
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
|
||||
|
|
@ -8,16 +6,35 @@ 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):
|
||||
|
|
@ -28,92 +45,72 @@ 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 __build_magnet(self, info_hash: str, title: str) -> str:
|
||||
return f"magnet:?xt=urn:btih:{info_hash}&dn={quote(title)}"
|
||||
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 __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")
|
||||
|
||||
logger.info(f"Searching Yggflix for movie: {media.titles[0]}")
|
||||
return self.yggflix.get_movie_torrents(media.tmdb_id)
|
||||
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 []
|
||||
|
||||
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:
|
||||
detail = self.yggflix.get_torrent_detail(torrent_id)
|
||||
return result, detail.get("hash")
|
||||
logger.info(f"Searching Yggflix for series: {media.titles[0]}")
|
||||
return self.yggflix.get_tvshow_torrents(int(media.tmdb_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"Yggflix: failed to get detail for torrent {torrent_id}: {e}")
|
||||
return result, None
|
||||
logger.error(
|
||||
f"Error searching Yggflix for series: {media.titles[0]}", exc_info=True
|
||||
)
|
||||
return []
|
||||
|
||||
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)
|
||||
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]}")
|
||||
logger.info(f"{len(results)} results found on Yggflix for: {media.titles[0]}")
|
||||
|
||||
items = []
|
||||
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}")
|
||||
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}")
|
||||
|
||||
return items
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import asyncio
|
||||
import aiohttp
|
||||
import requests
|
||||
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
|
||||
|
|
@ -18,7 +17,6 @@ class DMMImdbFile(BaseModel):
|
|||
adult: Optional[bool] = None
|
||||
year: Optional[int] = None
|
||||
|
||||
|
||||
class DMMImdbSearchResult(BaseModel):
|
||||
title: Optional[str] = None
|
||||
imdbId: Optional[str] = None
|
||||
|
|
@ -26,7 +24,6 @@ class DMMImdbSearchResult(BaseModel):
|
|||
score: float = 0.0
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class DMMTorrentInfo(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
|
@ -79,34 +76,37 @@ class DMMTorrentInfo(BaseModel):
|
|||
imdb_id: Optional[str] = None
|
||||
imdb: Optional[DMMImdbFile] = None
|
||||
|
||||
|
||||
class ZileanAPI:
|
||||
def __init__(self, session: Optional[aiohttp.ClientSession] = None):
|
||||
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,
|
||||
):
|
||||
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._external_session = session is not None
|
||||
self._session = session
|
||||
self._timeout = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
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)
|
||||
|
||||
# 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,55 +128,51 @@ 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]
|
||||
|
||||
async def _request(self, method: str, endpoint: str, cache: bool = True, **kwargs) -> Any:
|
||||
def _request(self, method: str, endpoint: str, cache: bool = True, **kwargs):
|
||||
"""
|
||||
Effectue une requête HTTP async avec gestion de cache.
|
||||
|
||||
Effectue une requête HTTP 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: Données JSON de la réponse
|
||||
:return: Réponse HTTP
|
||||
"""
|
||||
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, **params)
|
||||
cache_key = self._get_cache_key(method, endpoint, **kwargs)
|
||||
cached_data = self._get_from_cache(cache_key)
|
||||
if cached_data:
|
||||
return cached_data
|
||||
|
||||
session = await self._get_session()
|
||||
|
||||
return type('CachedResponse', (), {'json': lambda: cached_data, 'text': str(cached_data)})
|
||||
|
||||
try:
|
||||
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}")
|
||||
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}")
|
||||
raise
|
||||
|
||||
def _convert_to_dmm_torrent_info(self, entry: dict) -> DMMTorrentInfo:
|
||||
|
|
@ -187,11 +183,11 @@ class ZileanAPI:
|
|||
entry['imdb'] = DMMImdbFile(**entry['imdb'])
|
||||
return DMMTorrentInfo(**entry)
|
||||
|
||||
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_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_filtered(
|
||||
def dmm_filtered(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
|
|
@ -211,27 +207,23 @@ class ZileanAPI:
|
|||
"ImdbId": imdb_id,
|
||||
}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
data = await self._request("GET", "/dmm/filtered", params=params)
|
||||
return [self._convert_to_dmm_torrent_info(entry) for entry in data]
|
||||
response = self._request("GET", "/dmm/filtered", params=params)
|
||||
return [self._convert_to_dmm_torrent_info(entry) for entry in response.json()]
|
||||
|
||||
async def dmm_on_demand_scrape(self) -> None:
|
||||
await self._request("GET", "/dmm/on-demand-scrape", cache=False)
|
||||
def dmm_on_demand_scrape(self) -> None:
|
||||
self._request("GET", "/dmm/on-demand-scrape", cache=False)
|
||||
|
||||
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 healthchecks_ping(self) -> str:
|
||||
response = self._request("GET", "/healthchecks/ping", cache=False)
|
||||
return response.text
|
||||
|
||||
async def imdb_search(
|
||||
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}
|
||||
data = await self._request("POST", "/imdb/search", params=params)
|
||||
return [DMMImdbSearchResult(**file) for file in data]
|
||||
response = self._request("POST", "/imdb/search", params=params)
|
||||
return [DMMImdbSearchResult(**file) for file in response.json()]
|
||||
|
||||
def __del__(self):
|
||||
# Note: Ne pas fermer la session dans __del__ car c'est async
|
||||
# Utiliser close() explicitement ou un context manager
|
||||
pass
|
||||
self.session.close()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import aiohttp
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import List, Union, Dict, Tuple, Optional
|
||||
from functools import lru_cache
|
||||
import time
|
||||
|
||||
from stream_fusion.logging_config import logger
|
||||
|
|
@ -9,30 +10,29 @@ 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, session: Optional[aiohttp.ClientSession] = None):
|
||||
self.zilean_api = ZileanAPI(session=session)
|
||||
def __init__(self, config):
|
||||
self.zilean_api = ZileanAPI()
|
||||
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
|
||||
|
||||
async def search(self, media: Union[Movie, Series]) -> List[DMMTorrentInfo]:
|
||||
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 = await self.__search_movie(media)
|
||||
results = self.__search_movie(media)
|
||||
elif isinstance(media, Series):
|
||||
results = await self.__search_series(media)
|
||||
results = 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,96 +84,92 @@ class ZileanService:
|
|||
seen = set()
|
||||
return [title for title in titles if not (title.lower() in seen or seen.add(title.lower()))]
|
||||
|
||||
async def __search_movie(self, movie: Movie) -> List[DMMTorrentInfo]:
|
||||
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 = await self.__search_by_imdb_id(movie.id)
|
||||
|
||||
imdb_results = 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 en parallèle
|
||||
keyword_results = await self.__parallel_search_movie(unique_titles)
|
||||
|
||||
|
||||
# Sinon, compléter avec des recherches par titre
|
||||
keyword_results = self.__threaded_search_movie(unique_titles)
|
||||
|
||||
# Combiner et dédupliquer les résultats
|
||||
all_results = imdb_results + keyword_results
|
||||
return self.__deduplicate_api_results(all_results)
|
||||
|
||||
async def __search_series(self, series: Series) -> List[DMMTorrentInfo]:
|
||||
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 = await self.__search_by_imdb_id(series.id)
|
||||
|
||||
imdb_results = 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 en parallèle
|
||||
keyword_results = await self.__parallel_search_series(unique_titles, series)
|
||||
|
||||
|
||||
# Sinon, compléter avec des recherches par titre
|
||||
keyword_results = self.__threaded_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)
|
||||
|
||||
async def __parallel_search_movie(self, search_texts: List[str]) -> 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_movie_request(text) for text in search_texts]
|
||||
results_lists = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
def __threaded_search_movie(self, search_texts: List[str]) -> List[DMMTorrentInfo]:
|
||||
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)
|
||||
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
|
||||
|
||||
async def __parallel_search_series(self, search_texts: List[str], series: Series) -> List[DMMTorrentInfo]:
|
||||
"""Recherche parallèle avec asyncio.gather()."""
|
||||
def __threaded_search_series(self, search_texts: List[str], series: Series) -> List[DMMTorrentInfo]:
|
||||
results = []
|
||||
if not search_texts:
|
||||
return []
|
||||
|
||||
return results
|
||||
|
||||
# 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)
|
||||
|
||||
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)}")
|
||||
return results
|
||||
|
||||
async def __make_movie_request(self, query_text: str) -> List[DMMTorrentInfo]:
|
||||
def __make_movie_request(self, query_text: str) -> List[DMMTorrentInfo]:
|
||||
try:
|
||||
return await self.zilean_api.dmm_search(DMMQueryRequest(queryText=query_text))
|
||||
return 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 []
|
||||
|
||||
async def __make_series_request(self, query_text: str, series: Series) -> List[DMMTorrentInfo]:
|
||||
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 await self.zilean_api.dmm_filtered(
|
||||
|
||||
return self.zilean_api.dmm_filtered(
|
||||
query=query_text,
|
||||
season=season,
|
||||
episode=episode
|
||||
|
|
@ -182,13 +178,9 @@ class ZileanService:
|
|||
self.logger.exception(f"An exception occurred while searching for series '{query_text}' on Zilean: {str(e)}")
|
||||
return []
|
||||
|
||||
async def __search_by_imdb_id(self, imdb_id: str) -> List[DMMTorrentInfo]:
|
||||
def __search_by_imdb_id(self, imdb_id: str) -> List[DMMTorrentInfo]:
|
||||
try:
|
||||
return await self.zilean_api.dmm_filtered(imdb_id=imdb_id)
|
||||
return 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()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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
|
||||
|
|
@ -24,7 +25,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, pool_size=settings.pg_pool_size, max_overflow=settings.pg_max_overflow)
|
||||
engine = create_async_engine(str(settings.pg_url), echo=settings.pg_echo)
|
||||
session_factory = async_sessionmaker(
|
||||
engine,
|
||||
expire_on_commit=False,
|
||||
|
|
@ -32,6 +33,8 @@ 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
|
||||
|
|
@ -44,8 +47,17 @@ 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:
|
||||
|
|
@ -60,16 +72,8 @@ 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=200
|
||||
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db, max_connections=50
|
||||
)
|
||||
|
||||
yield
|
||||
|
|
@ -77,8 +81,6 @@ 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()
|
||||
|
|
@ -7,6 +7,8 @@ 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
|
||||
|
|
@ -20,8 +22,6 @@ 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,32 +35,13 @@ 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,
|
||||
|
|
@ -132,10 +113,13 @@ class ProxyStreamer:
|
|||
|
||||
|
||||
async def handle_download(
|
||||
query: dict, config: dict, ip: str, redis_cache: RedisCache, debrid_session=None
|
||||
query: dict, config: dict, ip: str, redis_cache: RedisCache
|
||||
) -> str:
|
||||
api_key = config.get("apiKey")
|
||||
cache_key = f"download:{api_key}:{json.dumps(query)}_{ip}"
|
||||
|
||||
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")
|
||||
|
|
@ -147,12 +131,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_session)
|
||||
debrid_service = get_download_service(config)
|
||||
if debrid_service:
|
||||
try:
|
||||
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)
|
||||
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)
|
||||
logger.info("Playback: Direct link generated and cached")
|
||||
return direct_link
|
||||
except Exception:
|
||||
|
|
@ -161,30 +145,54 @@ 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_session)
|
||||
debrid_service = get_download_service(config)
|
||||
if debrid_service:
|
||||
try:
|
||||
direct_link = await debrid_service.get_stream_link(query, config, ip)
|
||||
direct_link = 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)
|
||||
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)
|
||||
await redis_cache.set(direct_link_cache_key, direct_link, expiration=600)
|
||||
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
|
||||
|
|
@ -193,14 +201,14 @@ async def handle_download(
|
|||
)
|
||||
|
||||
try:
|
||||
debrid_service = get_download_service(config, debrid_session)
|
||||
debrid_service = get_download_service(config)
|
||||
if not debrid_service:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Download service not available"
|
||||
)
|
||||
|
||||
if isinstance(debrid_service, RealDebrid):
|
||||
torrent_id = await debrid_service.add_magnet_or_torrent_and_select(query, ip)
|
||||
torrent_id = debrid_service.add_magnet_or_torrent_and_select(query, ip)
|
||||
logger.success(
|
||||
f"Playback: Added magnet or torrent to Real-Debrid: {torrent_id}"
|
||||
)
|
||||
|
|
@ -217,7 +225,7 @@ async def handle_download(
|
|||
else None
|
||||
)
|
||||
privacy = query.get("privacy", "private")
|
||||
torrent_info = await debrid_service.add_magnet_or_torrent(
|
||||
torrent_info = debrid_service.add_magnet_or_torrent(
|
||||
magnet, torrent_download, ip, privacy
|
||||
)
|
||||
logger.success(
|
||||
|
|
@ -236,7 +244,7 @@ async def handle_download(
|
|||
else None
|
||||
)
|
||||
try:
|
||||
if await debrid_service.start_background_caching(magnet, query):
|
||||
if debrid_service.start_background_caching(magnet, query):
|
||||
logger.success(
|
||||
f"Playback: Started background caching for magnet: {magnet[:50]}"
|
||||
)
|
||||
|
|
@ -245,8 +253,6 @@ 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(
|
||||
|
|
@ -257,19 +263,11 @@ 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, debrid_session=None
|
||||
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache, cache_user_identifier: str, stream_id: str = None
|
||||
) -> str:
|
||||
logger.debug(f"Playback: Getting stream link for query: {decoded_query}, IP: {ip}")
|
||||
|
||||
|
|
@ -304,21 +302,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_session)
|
||||
|
||||
debrid_service = get_download_service(config)
|
||||
|
||||
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 = await debrid_service.get_stream_link(query, config, ip)
|
||||
link = debrid_service.get_stream_link(query, config, ip)
|
||||
|
||||
if link is None:
|
||||
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")
|
||||
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")
|
||||
else:
|
||||
logger.error("Playback: Service not found in query")
|
||||
raise HTTPException(status_code=500, detail="Service not found in query")
|
||||
|
|
@ -332,7 +330,8 @@ async def get_stream_link(
|
|||
return link
|
||||
|
||||
|
||||
@router.api_route("/{config}/{query:path}", methods=["GET", "HEAD"], responses={500: {"model": ErrorResponse}})
|
||||
@router.get("/{config}/{query}", responses={500: {"model": ErrorResponse}})
|
||||
@rate_limiter(limit=100, seconds=60, redis=redis_session)
|
||||
async def get_playback(
|
||||
config: str,
|
||||
query: str,
|
||||
|
|
@ -340,12 +339,10 @@ 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 = get_client_ip(request)
|
||||
ip = request.client.host
|
||||
cache_user_identifier = api_key if api_key else ip
|
||||
|
||||
# Only validate the API key if it exists
|
||||
|
|
@ -370,62 +367,69 @@ 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":
|
||||
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)
|
||||
# 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)
|
||||
|
||||
# Extract stream_id from referer
|
||||
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 cache_user_identifier for lock key
|
||||
lock_key = f"lock:stream:{cache_user_identifier}:{decoded_query}"
|
||||
lock = redis_client.lock(lock_key, timeout=60)
|
||||
|
||||
# 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)
|
||||
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:
|
||||
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)")
|
||||
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:
|
||||
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
|
||||
|
|
@ -443,7 +447,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_302_FOUND
|
||||
url=link, status_code=status.HTTP_301_MOVED_PERMANENTLY
|
||||
)
|
||||
|
||||
if service == "TB": # TODO: Check this for torbox
|
||||
|
|
@ -498,10 +502,6 @@ 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,5 +512,98 @@ async def get_playback(
|
|||
)
|
||||
|
||||
|
||||
# HEAD handler removed - FastAPI automatically handles HEAD requests
|
||||
# by routing them to the GET handler (same behavior as Comet)
|
||||
@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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class Video(BaseModel):
|
|||
|
||||
class Meta(BaseModel):
|
||||
id: str # = Field(alias="_id")
|
||||
name: str
|
||||
name: str = Field(alias="title")
|
||||
type: str = Field(default="movie")
|
||||
poster: str | None = None
|
||||
background: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
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,
|
||||
|
|
@ -23,6 +24,8 @@ 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"
|
||||
|
|
@ -74,17 +77,10 @@ def extract_year(date_string):
|
|||
return None
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
async def create_meta_object(details, item_type: str, imdb_id: str):
|
||||
meta = Meta(
|
||||
id=imdb_id,
|
||||
name=getattr(details, "title", None) or getattr(details, "name", None),
|
||||
title=getattr(details, "title", None) or getattr(details, "name", None),
|
||||
type=item_type,
|
||||
poster=(
|
||||
f"https://image.tmdb.org/t/p/w500{details.poster_path}"
|
||||
|
|
@ -119,8 +115,7 @@ async def create_meta_object(details, item_type: str, imdb_id: str, include_epis
|
|||
meta.stream = {
|
||||
"id": imdb_id
|
||||
}
|
||||
elif item_type == "series" and include_episodes and hasattr(details, "seasons"):
|
||||
# Charger les épisodes seulement si demandé (pour /meta, pas pour /catalog)
|
||||
elif item_type == "series" and hasattr(details, "seasons"):
|
||||
meta.videos = []
|
||||
for season in details.seasons:
|
||||
season_details = await get_tv_season_details(
|
||||
|
|
@ -155,6 +150,7 @@ 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,
|
||||
|
|
@ -162,8 +158,7 @@ async def get_catalog(
|
|||
request: Request,
|
||||
skip: int = 0,
|
||||
redis_client: Redis = Depends(get_redis),
|
||||
apikey_dao: APIKeyDAO = Depends(),
|
||||
torrentitem_dao: TorrentItemDAO = Depends()
|
||||
apikey_dao: APIKeyDAO = Depends()
|
||||
):
|
||||
try:
|
||||
config_data = parse_config(config)
|
||||
|
|
@ -178,8 +173,12 @@ 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")
|
||||
|
||||
|
|
@ -193,105 +192,48 @@ async def get_catalog(
|
|||
logger.info(f"Catalog not found in cache for key: {cache_key}. Generating...")
|
||||
|
||||
item_ids = []
|
||||
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",
|
||||
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",
|
||||
}
|
||||
|
||||
item_type = catalog_type_map.get(id)
|
||||
|
||||
# For "latest_*": Different logic for movies vs series
|
||||
if id.startswith("latest_"):
|
||||
if use_yggflix and id in yggflix_ids:
|
||||
try:
|
||||
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}")
|
||||
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.")
|
||||
item_ids = []
|
||||
|
||||
# Fallback to TMDb discover if no results
|
||||
if not item_ids:
|
||||
logger.info(f"Fallback: Fetching catalog from TMDb discover for type: {type}, id: {id}")
|
||||
logger.info(f"Fetching catalog from TMDb for type: {type}, id: {id}")
|
||||
try:
|
||||
tmdb_params = {"page": 1}
|
||||
if type == "movie":
|
||||
discover_func = discover.discover_movies
|
||||
else:
|
||||
discover_func = discover.discover_tv_shows
|
||||
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)
|
||||
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)
|
||||
|
||||
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 discover for {type}/{id}")
|
||||
logger.info(f"Fetched {len(item_ids)} IDs from TMDb 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()
|
||||
|
|
@ -303,19 +245,7 @@ async def get_catalog(
|
|||
|
||||
if cached_item:
|
||||
try:
|
||||
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)
|
||||
metas.append(Meta.model_validate(cached_item))
|
||||
except Exception as validation_error:
|
||||
logger.warning(f"Failed to validate cached meta for TMDB ID {tmdb_id}: {validation_error}")
|
||||
continue
|
||||
|
|
@ -328,25 +258,18 @@ async def get_catalog(
|
|||
else:
|
||||
details = await get_tv_details(tmdb_id)
|
||||
item_type = "series"
|
||||
# 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")
|
||||
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
|
||||
|
||||
# include_episodes=False pour le catalogue (pas besoin des épisodes)
|
||||
meta = await create_meta_object(details, item_type, imdb_id, include_episodes=False)
|
||||
meta = await create_meta_object(details, item_type, imdb_id)
|
||||
metas.append(meta)
|
||||
|
||||
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
|
||||
)
|
||||
|
|
@ -359,20 +282,6 @@ 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
|
||||
|
|
@ -401,6 +310,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -35,8 +35,6 @@ 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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
221
stream_fusion/web/root/search/stremio_parser.py
Normal file
221
stream_fusion/web/root/search/stremio_parser.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
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
|
||||
|
|
@ -25,7 +25,6 @@ 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
|
||||
|
|
@ -34,41 +33,29 @@ 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,
|
||||
|
|
@ -77,45 +64,29 @@ 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(fetch_next_metadata, next_episode_id, stream_type, config["metadataProvider"]),
|
||||
redis_cache.get_or_set(lambda: get_metadata(next_episode_id, stream_type), 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)
|
||||
|
||||
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)}")
|
||||
|
||||
|
||||
# Zilean
|
||||
if config["zilean"]:
|
||||
zilean_service = ZileanService(config, session=http_session)
|
||||
zilean_search_results = await zilean_service.search(next_media)
|
||||
zilean_service = ZileanService(config)
|
||||
zilean_search_results = zilean_service.search(next_media)
|
||||
if zilean_search_results:
|
||||
zilean_search_results = [
|
||||
ZileanResult().from_api_cached_item(torrent, next_media)
|
||||
|
|
@ -125,67 +96,41 @@ 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)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# 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 search_results:
|
||||
# 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)
|
||||
# Traitement des résultats
|
||||
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 = get_client_ip(request)
|
||||
result = await debrid.get_availability_bulk(hashes, ip)
|
||||
ip = request.client.host
|
||||
result = 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 = await parser.parse_to_stremio_streams(best_matching_results, next_media)
|
||||
stream_list = 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:
|
||||
|
|
@ -203,6 +148,7 @@ 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)
|
||||
|
||||
|
|
@ -226,12 +172,9 @@ 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(fetch_next_metadata, next_episode_id, stream_type, config["metadataProvider"]),
|
||||
redis_cache.get_or_set(lambda: get_metadata(next_episode_id, stream_type), next_episode_id, stream_type, config["metadataProvider"]),
|
||||
timeout=3.0
|
||||
)
|
||||
logger.debug(f"Pre-fetch: Metadata cached for episode {next_episode_id}")
|
||||
|
|
@ -246,6 +189,7 @@ 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", ""))
|
||||
|
|
@ -267,14 +211,11 @@ 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(fetch_next_metadata, next_episode_id, stream_type, config["metadataProvider"]),
|
||||
redis_cache.get_or_set(lambda: get_metadata(next_episode_id, stream_type), next_episode_id, stream_type, config["metadataProvider"]),
|
||||
timeout=8.0
|
||||
)
|
||||
|
||||
|
|
@ -295,6 +236,7 @@ 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)}")
|
||||
|
|
@ -318,7 +260,7 @@ async def get_results(
|
|||
stream_id = stream_id.replace(".json", "")
|
||||
config = parse_config(config)
|
||||
api_key = config.get("apiKey")
|
||||
ip_address = get_client_ip(request)
|
||||
ip_address = request.client.host
|
||||
# Only validate the API key if it exists
|
||||
if api_key:
|
||||
await check_api_key(api_key, apikey_dao)
|
||||
|
|
@ -326,29 +268,21 @@ 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_session = getattr(request.app.state, 'debrid_session', None)
|
||||
debrid_services = get_all_debrid_services(config, debrid_session)
|
||||
debrid_services = get_all_debrid_services(config)
|
||||
logger.debug(f"Search: Found {len(debrid_services)} debrid services")
|
||||
logger.info(
|
||||
f"Search: Debrid services: {[debrid.__class__.__name__ for debrid in debrid_services]}"
|
||||
)
|
||||
|
||||
http_session = getattr(request.app.state, 'http_session', None)
|
||||
|
||||
async def get_metadata(episode_id=None, media_type=None):
|
||||
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
|
||||
|
||||
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)
|
||||
return metadata_provider.get_metadata(actual_id, actual_type)
|
||||
|
||||
media = await redis_cache.get_or_set(
|
||||
get_metadata, stream_id, stream_type, config["metadataProvider"]
|
||||
|
|
@ -372,11 +306,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)
|
||||
|
|
@ -399,69 +333,8 @@ async def get_results(
|
|||
nonlocal search_results
|
||||
search_results = []
|
||||
|
||||
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 config["cache"] and not update_cache:
|
||||
public_cached_results = search_public(media)
|
||||
if public_cached_results:
|
||||
logger.success(
|
||||
f"Search: Found {len(public_cached_results)} public cached results"
|
||||
|
|
@ -471,17 +344,18 @@ 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 = merge_items(search_results, public_cached_results)
|
||||
search_results.extend(public_cached_results)
|
||||
|
||||
# 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)
|
||||
# Prioriser Zilean en premier
|
||||
if config["zilean"]:
|
||||
zilean_service = ZileanService(config)
|
||||
zilean_search_results = zilean_service.search(media)
|
||||
if zilean_search_results:
|
||||
logger.success(
|
||||
f"Search: Found {len(zilean_search_results)} results from Zilean"
|
||||
|
|
@ -491,6 +365,9 @@ 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
|
||||
)
|
||||
|
|
@ -499,16 +376,37 @@ 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, session=http_session)
|
||||
sharewood_search_results = await sharewood_service.search(media)
|
||||
sharewood_service = SharewoodService(config)
|
||||
sharewood_search_results = 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
|
||||
|
|
@ -521,14 +419,17 @@ async def get_results(
|
|||
if config["jackett"] and len(search_results) < int(
|
||||
config["minCachedResults"]
|
||||
):
|
||||
jackett_service = JackettService(config, session=http_session)
|
||||
jackett_search_results = await jackett_service.search(media)
|
||||
jackett_service = JackettService(config)
|
||||
jackett_search_results = jackett_service.search(media)
|
||||
logger.success(
|
||||
f"Search: Found {len(jackett_search_results)} results from Jackett"
|
||||
)
|
||||
if jackett_search_results:
|
||||
filtered_jackett_search_results = filter_items(
|
||||
jackett_search_results, media, config=config
|
||||
)
|
||||
if filtered_jackett_search_results:
|
||||
torrent_results = await torrent_service.convert_and_process(
|
||||
jackett_search_results
|
||||
filtered_jackett_search_results
|
||||
)
|
||||
search_results = merge_items(search_results, torrent_results)
|
||||
|
||||
|
|
@ -548,62 +449,40 @@ async def get_results(
|
|||
return search_results
|
||||
|
||||
async def get_and_filter_results(media, config):
|
||||
# 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)}")
|
||||
|
||||
min_results = int(config.get("minCachedResults", 5))
|
||||
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)"
|
||||
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"
|
||||
)
|
||||
return nocache_results
|
||||
else:
|
||||
logger.success(
|
||||
f"Search: Retrieved {len(external_results)} external results from Redis cache"
|
||||
logger.info(
|
||||
f"Search: Retrieved {len(unfiltered_results)} results from redis cache"
|
||||
)
|
||||
external_results = [
|
||||
TorrentItem.from_dict(item) for item in external_results
|
||||
unfiltered_results = [
|
||||
TorrentItem.from_dict(item) for item in unfiltered_results
|
||||
]
|
||||
|
||||
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")
|
||||
filtered_results = filter_items(unfiltered_results, media, config=config)
|
||||
|
||||
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."
|
||||
if len(filtered_results) < min_results:
|
||||
logger.info(
|
||||
f"Search: Insufficient filtered results ({len(filtered_results)}). Searching for new torrents to add."
|
||||
)
|
||||
await redis_cache.delete(cache_key)
|
||||
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)
|
||||
# Ne pas supprimer le cache, chercher de nouveaux torrents
|
||||
new_results = await get_search_results(media, config)
|
||||
# Fusionner anciens + nouveaux (merge_items déduplique automatiquement)
|
||||
all_results = merge_items(unfiltered_results, new_results)
|
||||
# Mettre à jour le cache avec tous les résultats
|
||||
await redis_cache.set(cache_key, [item.to_dict() for item in all_results], expiration=settings.redis_expiration)
|
||||
# Refiltrer avec tous les résultats
|
||||
filtered_results = filter_items(all_results, media, config=config)
|
||||
|
||||
logger.success(
|
||||
|
|
@ -616,24 +495,26 @@ async def get_results(
|
|||
search_results = ResultsPerQualityFilter(config).filter(raw_search_results)
|
||||
logger.info(f"Search: Filtered search results per quality: {len(search_results)}")
|
||||
|
||||
async def stream_processing(search_results, media, config):
|
||||
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 = get_client_ip(request)
|
||||
result = await debrid.get_availability_bulk(hashes, ip)
|
||||
ip = request.client.host
|
||||
result = 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:
|
||||
else: # Si c'est une liste (comme pour StremThru)
|
||||
count = len(result)
|
||||
|
||||
is_stremthru = (type(debrid).__name__ == "StremThru" or
|
||||
|
||||
# Déterminer si c'est StremThru pour ajuster la durée du cache
|
||||
is_stremthru = (type(debrid).__name__ == "StremThru" or
|
||||
hasattr(debrid, 'store_name') and getattr(debrid, 'store_name', None) is not None)
|
||||
|
||||
logger.info(
|
||||
|
|
@ -645,6 +526,7 @@ 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()
|
||||
|
|
@ -652,28 +534,38 @@ async def get_results(
|
|||
logger.info(f"Search: Found {len(best_matching_results)} best matching results")
|
||||
|
||||
parser = StreamParser(config)
|
||||
stream_list = await parser.parse_to_stremio_streams(best_matching_results, media)
|
||||
stream_list = 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 = await stream_processing(search_results, media, config)
|
||||
stream_list = 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
|
||||
has_stremthru = any(
|
||||
type(debrid).__name__ == "StremThru" or hasattr(debrid, 'store_name')
|
||||
for debrid in debrid_services
|
||||
)
|
||||
|
||||
# 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
|
||||
if has_stremthru:
|
||||
expiration_time = 600
|
||||
logger.info(f"Search: Using reduced cache expiration ({expiration_time}s) for StremThru")
|
||||
|
||||
logger.info(f"Search: Using reduced cache expiration time of {expiration_time} seconds for StremThru")
|
||||
|
||||
# Mettre en cache les résultats IMMÉDIATEMENT
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue