Compare commits
97 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a1386d5b | ||
|
|
a834fea508 | ||
|
|
817be7fd7d | ||
|
|
4fc4a5cacc | ||
|
|
563a4129a8 | ||
|
|
bd66a772f7 | ||
|
|
40bdc41334 | ||
|
|
15029df552 | ||
|
|
02b3794ce6 | ||
|
|
465ea3ce22 | ||
|
|
26c2cee770 | ||
|
|
12c9c78368 | ||
|
|
b562f09859 | ||
|
|
ec6883be22 | ||
|
|
fd0c29fb01 | ||
|
|
ca8479e924 | ||
|
|
3c57fe0011 | ||
|
|
3f9039622d | ||
|
|
0ce7888fd7 | ||
|
|
4871acd4b6 | ||
|
|
57eae813bb | ||
|
|
d47d4b5d83 | ||
|
|
2a0eba1eff | ||
|
|
9474f4ee61 | ||
|
|
cc4cd54e80 | ||
|
|
de2654a345 | ||
|
|
ee8e37b02e | ||
|
|
250dead391 | ||
|
|
f3667a68d0 | ||
|
|
834939756b | ||
|
|
82e12bf6d8 | ||
|
|
8bf9ce6545 | ||
|
|
35f0c45ada | ||
|
|
5772a7441c | ||
|
|
888ac83384 | ||
|
|
84b102c2f0 | ||
|
|
61e7298f26 | ||
|
|
ae296e4949 | ||
|
|
02df4f4f7f | ||
|
|
255470cafa | ||
|
|
3e19c889cc | ||
|
|
1535082d31 | ||
|
|
7cbca804f9 | ||
|
|
714b683c0b | ||
|
|
79c720e8a5 | ||
|
|
0955bf80aa | ||
|
|
922992f166 | ||
|
|
c2a780a909 | ||
|
|
f34513e2d6 | ||
|
|
a0e3e477b1 | ||
|
|
e2e2f59d7c | ||
|
|
30f28b8da5 | ||
|
|
5a48e8c5e6 | ||
|
|
eaa42d52b7 | ||
|
|
b4446353fe | ||
|
|
a966d33de5 | ||
|
|
b570822ad6 | ||
|
|
8fc8c70e83 | ||
|
|
90cb7e70fd | ||
|
|
7be417f4c1 | ||
|
|
2c1892c260 | ||
|
|
f2d7e1589f | ||
|
|
86d37233c9 | ||
|
|
e196b6413a | ||
|
|
88e2ffe875 | ||
|
|
7a5caab47c | ||
|
|
ab7e8bfaf3 | ||
|
|
202a5531d8 | ||
|
|
a7bfa9dcd2 | ||
|
|
6f1c16ee06 | ||
|
|
ddeec4d5ab | ||
|
|
5f5173f26c | ||
|
|
eb1fafbd67 | ||
|
|
b73dcf67b5 | ||
|
|
37c7948b60 | ||
|
|
972107876f | ||
|
|
35edbcf29e | ||
|
|
f439065a18 | ||
|
|
fcc1ee1cc9 | ||
|
|
a1f1c1eb2d | ||
|
|
be4e2f6e32 | ||
|
|
a171a9784a | ||
|
|
03fc7a3aee | ||
|
|
26dbfd9fe5 | ||
|
|
b0fe2f4c78 | ||
|
|
9381cd03c1 | ||
|
|
d0cf787650 | ||
|
|
f03dbf473d | ||
|
|
c76df27bca | ||
|
|
83537f2785 | ||
|
|
35d35993a3 | ||
|
|
b292f7d795 | ||
|
|
a8904f32ca | ||
|
|
f312c5fcce | ||
|
|
42503ea6da | ||
|
|
e3ed8b57f4 | ||
|
|
1530d0557c |
84 changed files with 6310 additions and 2178 deletions
9
.github/workflows/docker-release.yml
vendored
9
.github/workflows/docker-release.yml
vendored
|
|
@ -3,6 +3,11 @@ name: Build and Push Docker image to GitHub Packages on Release
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to build and push as latest (e.g. v2.3.5)'
|
||||||
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -11,6 +16,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.tag || github.event.release.tag_name }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
@ -35,7 +42,7 @@ jobs:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/telkaoss/stream-fusion:latest
|
ghcr.io/telkaoss/stream-fusion:latest
|
||||||
ghcr.io/telkaoss/stream-fusion:${{ github.event.release.tag_name }}
|
ghcr.io/telkaoss/stream-fusion:${{ github.event.inputs.tag || github.event.release.tag_name }}
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
org.opencontainers.image.description=Docker image for Stream Fusion
|
org.opencontainers.image.description=Docker image for Stream Fusion
|
||||||
|
|
|
||||||
0
.windsurfrules
Normal file
0
.windsurfrules
Normal file
528
poetry.lock
generated
528
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -35,7 +35,7 @@ timeout-decorator = "^0.5.0"
|
||||||
tenacity = "^8.5.0"
|
tenacity = "^8.5.0"
|
||||||
aiohttp-socks = "^0.8.4"
|
aiohttp-socks = "^0.8.4"
|
||||||
tmdbv3api = "^1.9.0"
|
tmdbv3api = "^1.9.0"
|
||||||
rank-torrent-name = "^1.2.0"
|
rank-torrent-name = "^1.9.0"
|
||||||
orjson = "^3.10.7"
|
orjson = "^3.10.7"
|
||||||
alembic = "^1.13.2"
|
alembic = "^1.13.2"
|
||||||
ujson = "^5.10.0"
|
ujson = "^5.10.0"
|
||||||
|
|
|
||||||
|
|
@ -1 +1,11 @@
|
||||||
"""stream_fusion package."""
|
"""stream_fusion package."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from .services.postgresql.db_init import init_db
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
await init_db()
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ FRENCH_PATTERNS = {
|
||||||
"VQ": r"\b(?:VOQ|VQ)\b",
|
"VQ": r"\b(?:VOQ|VQ)\b",
|
||||||
"VOSTFR": r"\b(?:VOSTFR|SUBFRENCH)\b",
|
"VOSTFR": r"\b(?:VOSTFR|SUBFRENCH)\b",
|
||||||
"FRENCH": r"\b(?:FRENCH|FR)\b",
|
"FRENCH": r"\b(?:FRENCH|FR)\b",
|
||||||
|
"MULTI": r"\b(?:MULTI)\b",
|
||||||
}
|
}
|
||||||
|
|
||||||
class CustomException(Exception):
|
class CustomException(Exception):
|
||||||
|
|
|
||||||
5
stream_fusion/services/postgresql/__init__.py
Normal file
5
stream_fusion/services/postgresql/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Package pour la gestion de la base de données PostgreSQL."""
|
||||||
|
|
||||||
|
from .db_init import init_db
|
||||||
|
|
||||||
|
__all__ = ["init_db"]
|
||||||
|
|
@ -39,6 +39,7 @@ class APIKeyDAO:
|
||||||
never_expire=api_key_create.never_expire,
|
never_expire=api_key_create.never_expire,
|
||||||
expiration_date=expiration_timestamp,
|
expiration_date=expiration_timestamp,
|
||||||
name=api_key_create.name,
|
name=api_key_create.name,
|
||||||
|
proxied_links=api_key_create.proxied_links,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.session.add(new_key)
|
self.session.add(new_key)
|
||||||
|
|
@ -53,7 +54,8 @@ class APIKeyDAO:
|
||||||
expiration_date=timestamp_to_datetime(new_key.expiration_date),
|
expiration_date=timestamp_to_datetime(new_key.expiration_date),
|
||||||
latest_query_date=timestamp_to_datetime(new_key.latest_query_date),
|
latest_query_date=timestamp_to_datetime(new_key.latest_query_date),
|
||||||
total_queries=new_key.total_queries,
|
total_queries=new_key.total_queries,
|
||||||
name=new_key.name
|
name=new_key.name,
|
||||||
|
proxied_links=new_key.proxied_links
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating API key: {str(e)}")
|
logger.error(f"Error creating API key: {str(e)}")
|
||||||
|
|
@ -73,7 +75,8 @@ class APIKeyDAO:
|
||||||
expiration_date=timestamp_to_datetime(key.expiration_date),
|
expiration_date=timestamp_to_datetime(key.expiration_date),
|
||||||
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
||||||
total_queries=key.total_queries,
|
total_queries=key.total_queries,
|
||||||
name=key.name
|
name=key.name,
|
||||||
|
proxied_links=key.proxied_links
|
||||||
)
|
)
|
||||||
for key in result.scalars().all()
|
for key in result.scalars().all()
|
||||||
]
|
]
|
||||||
|
|
@ -98,7 +101,8 @@ class APIKeyDAO:
|
||||||
expiration_date=timestamp_to_datetime(db_key.expiration_date),
|
expiration_date=timestamp_to_datetime(db_key.expiration_date),
|
||||||
latest_query_date=timestamp_to_datetime(db_key.latest_query_date),
|
latest_query_date=timestamp_to_datetime(db_key.latest_query_date),
|
||||||
total_queries=db_key.total_queries,
|
total_queries=db_key.total_queries,
|
||||||
name=db_key.name
|
name=db_key.name,
|
||||||
|
proxied_links=db_key.proxied_links
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"API key not found: {api_key}")
|
logger.warning(f"API key not found: {api_key}")
|
||||||
|
|
@ -120,7 +124,8 @@ class APIKeyDAO:
|
||||||
expiration_date=timestamp_to_datetime(key.expiration_date),
|
expiration_date=timestamp_to_datetime(key.expiration_date),
|
||||||
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
||||||
total_queries=key.total_queries,
|
total_queries=key.total_queries,
|
||||||
name=key.name
|
name=key.name,
|
||||||
|
proxied_links=key.proxied_links
|
||||||
)
|
)
|
||||||
for key in result.scalars().all()
|
for key in result.scalars().all()
|
||||||
]
|
]
|
||||||
|
|
@ -147,6 +152,10 @@ class APIKeyDAO:
|
||||||
|
|
||||||
if not db_key.never_expire and update_data.expiration_date:
|
if not db_key.never_expire and update_data.expiration_date:
|
||||||
db_key.expiration_date = datetime_to_timestamp(update_data.expiration_date)
|
db_key.expiration_date = datetime_to_timestamp(update_data.expiration_date)
|
||||||
|
|
||||||
|
if update_data.proxied_links is not None:
|
||||||
|
db_key.proxied_links = update_data.proxied_links
|
||||||
|
logger.info(f"Updated proxied_links to {update_data.proxied_links} for API key: {api_key}")
|
||||||
|
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(db_key)
|
await self.session.refresh(db_key)
|
||||||
|
|
@ -160,7 +169,8 @@ class APIKeyDAO:
|
||||||
expiration_date=timestamp_to_datetime(db_key.expiration_date),
|
expiration_date=timestamp_to_datetime(db_key.expiration_date),
|
||||||
latest_query_date=timestamp_to_datetime(db_key.latest_query_date),
|
latest_query_date=timestamp_to_datetime(db_key.latest_query_date),
|
||||||
total_queries=db_key.total_queries,
|
total_queries=db_key.total_queries,
|
||||||
name=db_key.name
|
name=db_key.name,
|
||||||
|
proxied_links=db_key.proxied_links
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -189,76 +199,50 @@ class APIKeyDAO:
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
async def check_key(self, api_key: uuid.UUID) -> bool:
|
async def check_key(self, api_key: uuid.UUID) -> bool:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
query = select(APIKeyModel).where(
|
||||||
|
APIKeyModel.api_key == str(api_key),
|
||||||
|
APIKeyModel.is_active == True,
|
||||||
|
(APIKeyModel.never_expire == True) |
|
||||||
|
(APIKeyModel.expiration_date > datetime_to_timestamp(datetime.now(timezone.utc)))
|
||||||
|
)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
db_key = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if db_key:
|
||||||
|
# Enregistrer automatiquement l'utilisation (comme dans l'ancienne version)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db_key.latest_query_date = datetime_to_timestamp(now)
|
||||||
|
db_key.total_queries += 1
|
||||||
|
await self.session.commit()
|
||||||
|
logger.debug(f"Checked and recorded usage for API key {api_key}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.debug(f"Checked API key {api_key}: invalid")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.session.rollback()
|
||||||
|
logger.error(f"Error checking API key {api_key}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def record_query(self, api_key: uuid.UUID) -> None:
|
||||||
async with self.session.begin():
|
async with self.session.begin():
|
||||||
try:
|
try:
|
||||||
query = select(APIKeyModel).where(APIKeyModel.api_key == str(api_key))
|
query = select(APIKeyModel).where(APIKeyModel.api_key == str(api_key))
|
||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
db_key = result.scalar_one_or_none()
|
db_key = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not db_key or not db_key.is_active:
|
if db_key:
|
||||||
logger.warning(f"Invalid or inactive API key: {api_key}")
|
now = datetime.now(timezone.utc)
|
||||||
return False
|
db_key.latest_query_date = datetime_to_timestamp(now)
|
||||||
|
db_key.total_queries += 1
|
||||||
current_time = datetime_to_timestamp(datetime.now(timezone.utc))
|
await self.session.commit()
|
||||||
if not db_key.never_expire and db_key.expiration_date < current_time:
|
logger.debug(f"Recorded query for API key: {api_key}")
|
||||||
logger.warning(f"Expired API key: {api_key}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
db_key.total_queries += 1
|
|
||||||
db_key.latest_query_date = current_time
|
|
||||||
|
|
||||||
logger.debug(f"Valid API key used: {api_key}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking API key {api_key}: {str(e)}")
|
await self.session.rollback()
|
||||||
return False
|
logger.error(f"Error recording query for API key {api_key}: {str(e)}")
|
||||||
|
|
||||||
async def renew_key(self, api_key: uuid.UUID) -> APIKeyInDB:
|
|
||||||
try:
|
|
||||||
query = select(APIKeyModel).where(APIKeyModel.api_key == str(api_key))
|
|
||||||
result = await self.session.execute(query)
|
|
||||||
db_key = result.scalar_one_or_none()
|
|
||||||
if not db_key:
|
|
||||||
logger.warning(f"API key not found for renewal: {api_key}")
|
|
||||||
raise HTTPException(status_code=404, detail="API key not found")
|
|
||||||
|
|
||||||
db_key.is_active = True
|
|
||||||
|
|
||||||
current_time = datetime_to_timestamp(datetime.now(timezone.utc))
|
|
||||||
|
|
||||||
if db_key.never_expire:
|
|
||||||
logger.info(f"Renewing a non-expiring key: {api_key}")
|
|
||||||
else:
|
|
||||||
if db_key.expiration_date and db_key.expiration_date > current_time:
|
|
||||||
logger.info(f"Attempted to renew non-expired key: {api_key}")
|
|
||||||
else:
|
|
||||||
# Update the expiration date only if it's not a non-expiring key
|
|
||||||
db_key.expiration_date = datetime_to_timestamp(
|
|
||||||
datetime.now(timezone.utc) + timedelta(days=self.expiration_limit)
|
|
||||||
)
|
|
||||||
logger.info(f"Renewed expiring key: {api_key}")
|
|
||||||
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(db_key)
|
|
||||||
|
|
||||||
logger.success(f"Renewed API key: {api_key}")
|
|
||||||
|
|
||||||
return APIKeyInDB(
|
|
||||||
id=db_key.id,
|
|
||||||
api_key=db_key.api_key,
|
|
||||||
is_active=db_key.is_active,
|
|
||||||
never_expire=db_key.never_expire,
|
|
||||||
expiration_date=timestamp_to_datetime(db_key.expiration_date),
|
|
||||||
latest_query_date=timestamp_to_datetime(db_key.latest_query_date),
|
|
||||||
total_queries=db_key.total_queries,
|
|
||||||
name=db_key.name
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
await self.session.rollback()
|
|
||||||
logger.error(f"Error renewing API key {api_key}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
async def get_usage_stats(self) -> List[APIKeyInDB]:
|
async def get_usage_stats(self) -> List[APIKeyInDB]:
|
||||||
try:
|
try:
|
||||||
|
|
@ -273,7 +257,8 @@ class APIKeyDAO:
|
||||||
expiration_date=timestamp_to_datetime(key.expiration_date),
|
expiration_date=timestamp_to_datetime(key.expiration_date),
|
||||||
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
||||||
total_queries=key.total_queries,
|
total_queries=key.total_queries,
|
||||||
name=key.name
|
name=key.name,
|
||||||
|
proxied_links=key.proxied_links
|
||||||
)
|
)
|
||||||
for key in result.scalars().all()
|
for key in result.scalars().all()
|
||||||
]
|
]
|
||||||
|
|
@ -283,9 +268,42 @@ class APIKeyDAO:
|
||||||
logger.error(f"Error retrieving usage stats: {str(e)}")
|
logger.error(f"Error retrieving usage stats: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
async def list_active_keys(self) -> List[APIKeyInDB]:
|
async def revoke_key(self, api_key: uuid.UUID) -> bool:
|
||||||
|
"""Revoke (deactivate) an API key"""
|
||||||
try:
|
try:
|
||||||
query = select(APIKeyModel).where(APIKeyModel.is_active == True)
|
update_data = APIKeyUpdate(is_active=False)
|
||||||
|
await self.update_key(api_key, update_data)
|
||||||
|
logger.info(f"API key revoked (deactivated): {api_key}")
|
||||||
|
return True
|
||||||
|
except HTTPException as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
logger.warning(f"API key not found for revocation: {api_key}")
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error revoking API key {api_key}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
async def renew_key(self, api_key: uuid.UUID) -> APIKeyInDB:
|
||||||
|
"""Renew (reactivate) an API key"""
|
||||||
|
try:
|
||||||
|
update_data = APIKeyUpdate(is_active=True)
|
||||||
|
updated_key = await self.update_key(api_key, update_data)
|
||||||
|
logger.info(f"API key renewed (reactivated): {api_key}")
|
||||||
|
return updated_key
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error renewing API key {api_key}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def list_active_keys(self) -> List[APIKeyInDB]:
|
||||||
|
"""List all active API keys"""
|
||||||
|
try:
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
query = select(APIKeyModel).where(
|
||||||
|
APIKeyModel.is_active == True,
|
||||||
|
(APIKeyModel.never_expire == True) |
|
||||||
|
(APIKeyModel.expiration_date > datetime_to_timestamp(current_time))
|
||||||
|
)
|
||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
keys = [
|
keys = [
|
||||||
APIKeyInDB(
|
APIKeyInDB(
|
||||||
|
|
@ -296,63 +314,13 @@ class APIKeyDAO:
|
||||||
expiration_date=timestamp_to_datetime(key.expiration_date),
|
expiration_date=timestamp_to_datetime(key.expiration_date),
|
||||||
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
latest_query_date=timestamp_to_datetime(key.latest_query_date),
|
||||||
total_queries=key.total_queries,
|
total_queries=key.total_queries,
|
||||||
name=key.name
|
name=key.name,
|
||||||
|
proxied_links=key.proxied_links
|
||||||
)
|
)
|
||||||
for key in result.scalars().all()
|
for key in result.scalars().all()
|
||||||
]
|
]
|
||||||
logger.info(f"Retrieved {len(keys)} active API keys")
|
logger.info(f"Retrieved {len(keys)} active API keys")
|
||||||
return keys
|
return keys
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing active keys: {str(e)}")
|
logger.error(f"Error retrieving active keys: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
async def update_key_name(self, api_key: uuid.UUID, new_name: str) -> APIKeyInDB:
|
|
||||||
try:
|
|
||||||
query = select(APIKeyModel).where(APIKeyModel.api_key == str(api_key))
|
|
||||||
result = await self.session.execute(query)
|
|
||||||
db_key = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not db_key:
|
|
||||||
logger.warning(f"API key not found for name update: {api_key}")
|
|
||||||
raise HTTPException(status_code=404, detail="API key not found")
|
|
||||||
|
|
||||||
db_key.name = new_name
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(db_key)
|
|
||||||
|
|
||||||
logger.info(f"Updated name for API key: {api_key}")
|
|
||||||
return APIKeyInDB(
|
|
||||||
id=db_key.id,
|
|
||||||
api_key=db_key.api_key,
|
|
||||||
is_active=db_key.is_active,
|
|
||||||
never_expire=db_key.never_expire,
|
|
||||||
expiration_date=timestamp_to_datetime(db_key.expiration_date),
|
|
||||||
latest_query_date=timestamp_to_datetime(db_key.latest_query_date),
|
|
||||||
total_queries=db_key.total_queries,
|
|
||||||
name=db_key.name
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
await self.session.rollback()
|
|
||||||
logger.error(f"Error updating name for API key {api_key}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
async def revoke_key(self, api_key: uuid.UUID) -> bool:
|
|
||||||
try:
|
|
||||||
query = select(APIKeyModel).where(APIKeyModel.api_key == str(api_key))
|
|
||||||
result = await self.session.execute(query)
|
|
||||||
db_key = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if db_key:
|
|
||||||
db_key.is_active = False
|
|
||||||
await self.session.commit()
|
|
||||||
logger.success(f"Revoked API key: {api_key}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"API key not found for revocation: {api_key}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
await self.session.rollback()
|
|
||||||
logger.error(f"Error revoking API key {api_key}: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from stream_fusion.services.postgresql.dependencies import get_db_session
|
from stream_fusion.services.postgresql.dependencies import get_db_session
|
||||||
from stream_fusion.services.postgresql.models.torrentitem_model import TorrentItemModel
|
from stream_fusion.services.postgresql.models.torrentitem_model import TorrentItemModel
|
||||||
|
|
@ -10,7 +10,6 @@ from stream_fusion.logging_config import logger
|
||||||
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
||||||
|
|
||||||
class TorrentItemDAO:
|
class TorrentItemDAO:
|
||||||
"""Class for accessing TorrentItem table."""
|
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession = Depends(get_db_session)) -> None:
|
def __init__(self, session: AsyncSession = Depends(get_db_session)) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
@ -23,11 +22,11 @@ class TorrentItemDAO:
|
||||||
self.session.add(new_item)
|
self.session.add(new_item)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await self.session.refresh(new_item)
|
await self.session.refresh(new_item)
|
||||||
|
|
||||||
logger.debug(f"TorrentItemDAO: Created new TorrentItem: {new_item.id}")
|
logger.debug(f"TorrentItemDAO: Created new TorrentItem: {new_item.id}")
|
||||||
return new_item
|
return new_item
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"TorrentItemDAO: Error creating TorrentItem: {str(e)}")
|
if "duplicate key value violates unique constraint" not in str(e):
|
||||||
|
logger.error(f"TorrentItemDAO: Error creating TorrentItem: {str(e)}")
|
||||||
|
|
||||||
async def get_all_torrent_items(self, limit: int, offset: int) -> List[TorrentItemModel]:
|
async def get_all_torrent_items(self, limit: int, offset: int) -> List[TorrentItemModel]:
|
||||||
async with self.session.begin():
|
async with self.session.begin():
|
||||||
|
|
@ -67,14 +66,18 @@ class TorrentItemDAO:
|
||||||
logger.warning(f"TorrentItemDAO: TorrentItem not found for update: {item_id}")
|
logger.warning(f"TorrentItemDAO: TorrentItem not found for update: {item_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Update fields
|
|
||||||
for key, value in torrent_item.__dict__.items():
|
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)
|
setattr(db_item, key, value)
|
||||||
|
|
||||||
db_item.updated_at = int(datetime.now(timezone.utc).timestamp())
|
db_item.updated_at = int(datetime.now(timezone.utc).timestamp())
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
await self.session.refresh(db_item)
|
await self.session.refresh(db_item)
|
||||||
|
|
||||||
logger.debug(f"TorrentItemDAO: Updated TorrentItem: {item_id}")
|
logger.debug(f"TorrentItemDAO: Updated TorrentItem: {item_id}")
|
||||||
return db_item
|
return db_item
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -110,7 +113,7 @@ class TorrentItemDAO:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by info_hash {info_hash}: {str(e)}")
|
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by info_hash {info_hash}: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_torrent_items_by_indexer(self, indexer: str) -> List[TorrentItemModel]:
|
async def get_torrent_items_by_indexer(self, indexer: str) -> List[TorrentItemModel]:
|
||||||
async with self.session.begin():
|
async with self.session.begin():
|
||||||
try:
|
try:
|
||||||
|
|
@ -122,7 +125,7 @@ class TorrentItemDAO:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by indexer {indexer}: {str(e)}")
|
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by indexer {indexer}: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def is_torrent_item_cached(self, item_id: str) -> bool:
|
async def is_torrent_item_cached(self, item_id: str) -> bool:
|
||||||
async with self.session.begin():
|
async with self.session.begin():
|
||||||
try:
|
try:
|
||||||
|
|
@ -158,4 +161,320 @@ class TorrentItemDAO:
|
||||||
return items
|
return items
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by availability {available}: {str(e)}")
|
logger.error(f"TorrentItemDAO: Error retrieving TorrentItems by availability {available}: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def search_by_info_hash(self, info_hash: str) -> Optional[TorrentItemModel]:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
query = select(TorrentItemModel).where(TorrentItemModel.info_hash == info_hash).limit(1)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if item:
|
||||||
|
logger.debug(f"TorrentItemDAO: Found torrent with info_hash: {info_hash}")
|
||||||
|
return item
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error searching by info_hash {info_hash}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_by_tmdb_id(self, tmdb_id: int) -> List[TorrentItemModel]:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
query = select(TorrentItemModel).where(TorrentItemModel.tmdb_id == tmdb_id)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
items = result.scalars().all()
|
||||||
|
logger.debug(f"TorrentItemDAO: Found {len(items)} torrents for TMDB ID: {tmdb_id}")
|
||||||
|
return items
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error searching by TMDB ID {tmdb_id}: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def update_torrent_file_path(self, torrent_id: str, file_path: str) -> bool:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
query = select(TorrentItemModel).where(TorrentItemModel.id == torrent_id)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
db_item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_item:
|
||||||
|
logger.warning(f"TorrentItemDAO: TorrentItem not found for file path update: {torrent_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
db_item.torrent_file_path = file_path
|
||||||
|
db_item.updated_at = int(datetime.now(timezone.utc).timestamp())
|
||||||
|
await self.session.flush()
|
||||||
|
await self.session.refresh(db_item)
|
||||||
|
logger.debug(f"TorrentItemDAO: Updated torrent_file_path for {torrent_id}: {file_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error updating torrent_file_path for {torrent_id}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_torrent_file_path_and_tmdb_id(self, torrent_id: str, file_path: str, tmdb_id: Optional[int]) -> bool:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
query = select(TorrentItemModel).where(TorrentItemModel.id == torrent_id)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
db_item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not db_item:
|
||||||
|
logger.warning(f"TorrentItemDAO: TorrentItem not found for update: {torrent_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
db_item.torrent_file_path = file_path
|
||||||
|
if tmdb_id:
|
||||||
|
db_item.tmdb_id = tmdb_id
|
||||||
|
db_item.updated_at = int(datetime.now(timezone.utc).timestamp())
|
||||||
|
await self.session.flush()
|
||||||
|
await self.session.refresh(db_item)
|
||||||
|
logger.debug(f"TorrentItemDAO: Updated torrent_file_path ({file_path}) and TMDB ID ({tmdb_id}) for {torrent_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error updating torrent_file_path and tmdb_id for {torrent_id}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_tmdb_id_by_raw_title(self, raw_title: str, tmdb_id: int) -> int:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
update(TorrentItemModel)
|
||||||
|
.where(TorrentItemModel.raw_title == raw_title)
|
||||||
|
.where(TorrentItemModel.tmdb_id.is_(None))
|
||||||
|
.values(
|
||||||
|
tmdb_id=tmdb_id,
|
||||||
|
updated_at=int(datetime.now(timezone.utc).timestamp())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
await self.session.flush()
|
||||||
|
row_count = result.rowcount
|
||||||
|
logger.debug(f"TorrentItemDAO: Updated {row_count} torrents with raw_title '{raw_title}' to tmdb_id {tmdb_id}")
|
||||||
|
return row_count
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error updating tmdb_id for raw_title '{raw_title}': {str(e)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def get_latest_tmdb_ids(self, item_type: str, limit: int = 50) -> List[int]:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
query = select(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
func.min(TorrentItemModel.created_at).label('first_seen')
|
||||||
|
).where(
|
||||||
|
TorrentItemModel.type == item_type,
|
||||||
|
TorrentItemModel.tmdb_id.isnot(None),
|
||||||
|
TorrentItemModel.indexer == "Yggtorrent - API",
|
||||||
|
TorrentItemModel.languages.any('fr')
|
||||||
|
).group_by(
|
||||||
|
TorrentItemModel.tmdb_id
|
||||||
|
).order_by(
|
||||||
|
func.min(TorrentItemModel.created_at).desc()
|
||||||
|
).limit(limit)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
rows = result.fetchall()
|
||||||
|
tmdb_ids = [row.tmdb_id for row in rows]
|
||||||
|
logger.debug(f"TorrentItemDAO: Retrieved {len(tmdb_ids)} latest TMDB IDs (FR/MULTI) for {item_type}")
|
||||||
|
return tmdb_ids
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error getting latest TMDB IDs for {item_type}: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_recently_added_tmdb_ids(self, item_type: str, limit: int = 50) -> List[int]:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
query = select(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
func.max(TorrentItemModel.created_at).label('last_added')
|
||||||
|
).where(
|
||||||
|
TorrentItemModel.type == item_type,
|
||||||
|
TorrentItemModel.tmdb_id.isnot(None),
|
||||||
|
TorrentItemModel.indexer == "Yggtorrent - API"
|
||||||
|
).group_by(
|
||||||
|
TorrentItemModel.tmdb_id
|
||||||
|
).order_by(
|
||||||
|
func.max(TorrentItemModel.created_at).desc()
|
||||||
|
).limit(limit)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
rows = result.fetchall()
|
||||||
|
tmdb_ids = [row.tmdb_id for row in rows]
|
||||||
|
logger.debug(f"TorrentItemDAO: Retrieved {len(tmdb_ids)} recently added TMDB IDs for {item_type}")
|
||||||
|
return tmdb_ids
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error getting recently added TMDB IDs for {item_type}: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_series_with_new_episodes(self, recent_days: int = 7, limit: int = 50) -> List[int]:
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
from sqlalchemy.sql.expression import literal_column
|
||||||
|
|
||||||
|
cutoff_timestamp = int((datetime.now(timezone.utc) - timedelta(days=recent_days)).timestamp())
|
||||||
|
|
||||||
|
subquery = select(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
literal_column("parsed_data->>'seasons'").label('season_json'),
|
||||||
|
literal_column("parsed_data->>'episodes'").label('episode_json'),
|
||||||
|
func.min(TorrentItemModel.created_at).label('first_seen')
|
||||||
|
).where(
|
||||||
|
TorrentItemModel.type == 'series',
|
||||||
|
TorrentItemModel.tmdb_id.isnot(None),
|
||||||
|
TorrentItemModel.parsed_data.isnot(None)
|
||||||
|
).group_by(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
literal_column("parsed_data->>'seasons'"),
|
||||||
|
literal_column("parsed_data->>'episodes'")
|
||||||
|
).having(
|
||||||
|
func.min(TorrentItemModel.created_at) >= cutoff_timestamp
|
||||||
|
).subquery()
|
||||||
|
|
||||||
|
query = select(
|
||||||
|
subquery.c.tmdb_id,
|
||||||
|
func.max(subquery.c.first_seen).label('latest_new_episode')
|
||||||
|
).group_by(
|
||||||
|
subquery.c.tmdb_id
|
||||||
|
).order_by(
|
||||||
|
func.max(subquery.c.first_seen).desc()
|
||||||
|
).limit(limit)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
rows = result.fetchall()
|
||||||
|
tmdb_ids = [row.tmdb_id for row in rows]
|
||||||
|
logger.debug(f"TorrentItemDAO: Retrieved {len(tmdb_ids)} series with new episodes (last {recent_days} days)")
|
||||||
|
return tmdb_ids
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error getting series with new episodes: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def filter_existing_tmdb_ids(self, tmdb_ids: List[int], item_type: str, recent_days: Optional[int] = None, sort_by_added: bool = False, return_episode_info: bool = False):
|
||||||
|
if not tmdb_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with self.session.begin():
|
||||||
|
try:
|
||||||
|
conditions = [
|
||||||
|
TorrentItemModel.tmdb_id.in_(tmdb_ids),
|
||||||
|
TorrentItemModel.type == item_type,
|
||||||
|
TorrentItemModel.indexer == "Yggtorrent - API",
|
||||||
|
]
|
||||||
|
|
||||||
|
if recent_days is not None:
|
||||||
|
cutoff_timestamp = int((datetime.now(timezone.utc) - timedelta(days=recent_days)).timestamp())
|
||||||
|
|
||||||
|
if item_type == "series":
|
||||||
|
from sqlalchemy.sql.expression import literal_column
|
||||||
|
|
||||||
|
subquery = select(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
literal_column("parsed_data->>'seasons'").label('season_json'),
|
||||||
|
literal_column("parsed_data->>'episodes'").label('episode_json'),
|
||||||
|
func.min(TorrentItemModel.created_at).label('first_seen')
|
||||||
|
).where(
|
||||||
|
TorrentItemModel.tmdb_id.in_(tmdb_ids),
|
||||||
|
TorrentItemModel.type == item_type,
|
||||||
|
TorrentItemModel.parsed_data.isnot(None)
|
||||||
|
).group_by(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
literal_column("parsed_data->>'seasons'"),
|
||||||
|
literal_column("parsed_data->>'episodes'")
|
||||||
|
).having(
|
||||||
|
func.min(TorrentItemModel.created_at) >= cutoff_timestamp
|
||||||
|
).subquery()
|
||||||
|
|
||||||
|
query = select(subquery.c.tmdb_id.distinct())
|
||||||
|
else:
|
||||||
|
conditions.append(TorrentItemModel.created_at >= cutoff_timestamp)
|
||||||
|
query = select(TorrentItemModel.tmdb_id.distinct()).where(*conditions)
|
||||||
|
else:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
conditions.append(or_(TorrentItemModel.languages.any('fr'), TorrentItemModel.languages.any('multi')))
|
||||||
|
if sort_by_added:
|
||||||
|
vostfr_conditions = conditions + [
|
||||||
|
~TorrentItemModel.raw_title.ilike('%VOSTFR%'),
|
||||||
|
~TorrentItemModel.raw_title.ilike('%FANSUB%'),
|
||||||
|
~TorrentItemModel.raw_title.ilike('%SUBFRENCH%'),
|
||||||
|
]
|
||||||
|
if item_type == "series":
|
||||||
|
from sqlalchemy.sql.expression import literal_column
|
||||||
|
|
||||||
|
subquery = select(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
literal_column("parsed_data->>'seasons'").label('season_json'),
|
||||||
|
literal_column("parsed_data->>'episodes'").label('episode_json'),
|
||||||
|
func.min(TorrentItemModel.created_at).label('first_seen')
|
||||||
|
).where(
|
||||||
|
*vostfr_conditions,
|
||||||
|
TorrentItemModel.parsed_data.isnot(None)
|
||||||
|
).group_by(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
literal_column("parsed_data->>'seasons'"),
|
||||||
|
literal_column("parsed_data->>'episodes'")
|
||||||
|
).subquery()
|
||||||
|
|
||||||
|
cutoff_30d = int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp())
|
||||||
|
|
||||||
|
if return_episode_info:
|
||||||
|
query = select(
|
||||||
|
subquery.c.tmdb_id,
|
||||||
|
subquery.c.season_json,
|
||||||
|
subquery.c.episode_json,
|
||||||
|
subquery.c.first_seen
|
||||||
|
).where(
|
||||||
|
subquery.c.first_seen >= cutoff_30d
|
||||||
|
).distinct(
|
||||||
|
subquery.c.tmdb_id
|
||||||
|
).order_by(
|
||||||
|
subquery.c.tmdb_id,
|
||||||
|
subquery.c.first_seen.desc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = select(
|
||||||
|
subquery.c.tmdb_id,
|
||||||
|
func.max(subquery.c.first_seen).label('latest_new_episode')
|
||||||
|
).group_by(
|
||||||
|
subquery.c.tmdb_id
|
||||||
|
).having(
|
||||||
|
func.max(subquery.c.first_seen) >= cutoff_30d
|
||||||
|
).order_by(
|
||||||
|
func.max(subquery.c.first_seen).desc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = select(
|
||||||
|
TorrentItemModel.tmdb_id,
|
||||||
|
func.min(TorrentItemModel.created_at).label('first_seen')
|
||||||
|
).where(*vostfr_conditions).group_by(
|
||||||
|
TorrentItemModel.tmdb_id
|
||||||
|
).order_by(
|
||||||
|
func.min(TorrentItemModel.created_at).desc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = select(TorrentItemModel.tmdb_id.distinct()).where(*conditions)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
if return_episode_info and item_type == "series" and sort_by_added:
|
||||||
|
episode_data = []
|
||||||
|
for row in rows:
|
||||||
|
episode_data.append({
|
||||||
|
'tmdb_id': row[0],
|
||||||
|
'season': row[1],
|
||||||
|
'episode': row[2],
|
||||||
|
'first_seen': row[3]
|
||||||
|
})
|
||||||
|
episode_data.sort(key=lambda x: x['first_seen'], reverse=True)
|
||||||
|
logger.debug(f"TorrentItemDAO: Filtered {len(tmdb_ids)} TMDB IDs to {len(episode_data)} with episode info for {item_type}")
|
||||||
|
return episode_data
|
||||||
|
elif sort_by_added and recent_days is None:
|
||||||
|
filtered_ids = [row[0] for row in rows]
|
||||||
|
else:
|
||||||
|
existing_ids = {row[0] for row in rows}
|
||||||
|
filtered_ids = [tid for tid in tmdb_ids if tid in existing_ids]
|
||||||
|
|
||||||
|
recent_info = f" (recent {recent_days}d, by episode)" if recent_days and item_type == "series" else (f" (recent {recent_days}d)" if recent_days else "")
|
||||||
|
sort_info = ", sorted by added date" if sort_by_added else ""
|
||||||
|
logger.debug(f"TorrentItemDAO: Filtered {len(tmdb_ids)} TMDB IDs to {len(filtered_ids)} existing (FR/MULTI{recent_info}{sort_info}) for {item_type}")
|
||||||
|
return filtered_ids
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TorrentItemDAO: Error filtering TMDB IDs for {item_type}: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
|
||||||
12
stream_fusion/services/postgresql/db_init.py
Normal file
12
stream_fusion/services/postgresql/db_init.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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():
|
||||||
|
engine = create_async_engine(str(settings.pg_url))
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""add proxied_links column
|
||||||
|
|
||||||
|
Revision ID: add_proxied_links
|
||||||
|
Revises: df288f2cf1fa
|
||||||
|
Create Date: 2025-01-11 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'add_proxied_links'
|
||||||
|
down_revision = 'df288f2cf1fa'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('api_keys', sa.Column('proxied_links', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('api_keys', 'proxied_links')
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""add tmdb_id column to torrent_items
|
||||||
|
|
||||||
|
Revision ID: add_tmdb_id
|
||||||
|
Revises: add_proxied_links
|
||||||
|
Create Date: 2025-11-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'add_tmdb_id'
|
||||||
|
down_revision = 'add_proxied_links'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('torrent_items', sa.Column('tmdb_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_torrent_items_tmdb_id'), 'torrent_items', ['tmdb_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_torrent_items_tmdb_id'), table_name='torrent_items')
|
||||||
|
op.drop_column('torrent_items', 'tmdb_id')
|
||||||
|
|
@ -20,6 +20,7 @@ class APIKeyModel(Base):
|
||||||
latest_query_date: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
latest_query_date: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||||
total_queries: Mapped[int] = mapped_column(default=0)
|
total_queries: Mapped[int] = mapped_column(default=0)
|
||||||
name: Mapped[str] = mapped_column(nullable=False)
|
name: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
proxied_links: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ class TorrentItemModel(Base):
|
||||||
indexer: Mapped[str] = mapped_column(String, nullable=False)
|
indexer: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
privacy: Mapped[str] = mapped_column(String, nullable=False)
|
privacy: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
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)
|
file_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
files: Mapped[Optional[List[dict]]] = mapped_column(JSON, nullable=True) # Kept as JSON
|
files: Mapped[Optional[List[dict]]] = mapped_column(JSON, nullable=True) # Kept as JSON
|
||||||
torrent_download: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
torrent_download: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
|
@ -40,8 +41,8 @@ class TorrentItemModel(Base):
|
||||||
updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
updated_at: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_unique_id(raw_title: str, size: int, indexer: str = "cached") -> str:
|
def generate_unique_id(raw_title: str, size: int, indexer: str = "cached", info_hash: str = "") -> str:
|
||||||
unique_string = f"{raw_title}_{size}_{indexer}"
|
unique_string = f"{raw_title}_{size}_{indexer}_{info_hash}"
|
||||||
full_hash = hashlib.sha256(unique_string.encode()).hexdigest()
|
full_hash = hashlib.sha256(unique_string.encode()).hexdigest()
|
||||||
return full_hash[:16]
|
return full_hash[:16]
|
||||||
|
|
||||||
|
|
@ -50,7 +51,8 @@ class TorrentItemModel(Base):
|
||||||
kwargs['id'] = self.generate_unique_id(
|
kwargs['id'] = self.generate_unique_id(
|
||||||
kwargs.get('raw_title', ''),
|
kwargs.get('raw_title', ''),
|
||||||
self._parse_size(kwargs.get('size', 0)),
|
self._parse_size(kwargs.get('size', 0)),
|
||||||
kwargs.get('indexer', 'cached')
|
kwargs.get('indexer', 'cached'),
|
||||||
|
kwargs.get('info_hash', '')
|
||||||
)
|
)
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
current_time = int(datetime.now().timestamp())
|
current_time = int(datetime.now().timestamp())
|
||||||
|
|
@ -59,6 +61,24 @@ class TorrentItemModel(Base):
|
||||||
if 'updated_at' not in kwargs:
|
if 'updated_at' not in kwargs:
|
||||||
self.updated_at = current_time
|
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
|
@classmethod
|
||||||
def from_torrent_item(cls, torrent_item: TorrentItem):
|
def from_torrent_item(cls, torrent_item: TorrentItem):
|
||||||
model_dict = {}
|
model_dict = {}
|
||||||
|
|
@ -67,13 +87,32 @@ class TorrentItemModel(Base):
|
||||||
if attr == 'size':
|
if attr == 'size':
|
||||||
model_dict[attr] = cls._parse_size(value)
|
model_dict[attr] = cls._parse_size(value)
|
||||||
elif attr in ['files', 'full_index']:
|
elif attr in ['files', 'full_index']:
|
||||||
model_dict[attr] = cls._parse_json(value)
|
# Remove ed2k bytes before JSON serialization
|
||||||
|
cleaned_value = cls._remove_ed2k_from_files(value)
|
||||||
|
model_dict[attr] = cls._parse_json(cleaned_value)
|
||||||
elif attr == 'parsed_data':
|
elif attr == 'parsed_data':
|
||||||
model_dict[attr] = value.model_dump() if value else None
|
if value:
|
||||||
|
try:
|
||||||
|
# Try to convert to dict if it's a Pydantic model
|
||||||
|
if hasattr(value, 'model_dump'):
|
||||||
|
model_dict[attr] = value.model_dump()
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
model_dict[attr] = value
|
||||||
|
else:
|
||||||
|
# Convert to dict representation
|
||||||
|
model_dict[attr] = vars(value) if hasattr(value, '__dict__') else str(value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not serialize parsed_data: {e}")
|
||||||
|
model_dict[attr] = None
|
||||||
|
else:
|
||||||
|
model_dict[attr] = None
|
||||||
elif attr == "availability":
|
elif attr == "availability":
|
||||||
model_dict[attr] = False
|
model_dict[attr] = False
|
||||||
elif attr == "seeders":
|
elif attr == "seeders":
|
||||||
model_dict[attr] = int(value) if value else 0
|
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:
|
else:
|
||||||
model_dict[attr] = value
|
model_dict[attr] = value
|
||||||
|
|
||||||
|
|
@ -81,13 +120,30 @@ class TorrentItemModel(Base):
|
||||||
|
|
||||||
def to_torrent_item(self):
|
def to_torrent_item(self):
|
||||||
from RTN.models import ParsedData
|
from RTN.models import ParsedData
|
||||||
|
from RTN import parse
|
||||||
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
||||||
|
|
||||||
torrent_item_dict = {}
|
torrent_item_dict = {}
|
||||||
|
raw_title = None
|
||||||
|
|
||||||
for attr, value in self.__dict__.items():
|
for attr, value in self.__dict__.items():
|
||||||
if attr not in ['_sa_instance_state', 'created_at', 'updated_at']:
|
if attr not in ['_sa_instance_state', 'created_at', 'updated_at']:
|
||||||
if attr == 'parsed_data':
|
if attr == 'raw_title':
|
||||||
torrent_item_dict[attr] = ParsedData(**value) if value else None
|
raw_title = value
|
||||||
|
torrent_item_dict[attr] = value
|
||||||
|
elif attr == 'parsed_data':
|
||||||
|
# Handle parsed_data conversion with validation
|
||||||
|
if value is None:
|
||||||
|
torrent_item_dict[attr] = None
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
try:
|
||||||
|
torrent_item_dict[attr] = ParsedData(**value)
|
||||||
|
except Exception:
|
||||||
|
# If parsing dict fails, reparse from raw_title
|
||||||
|
torrent_item_dict[attr] = parse(raw_title) if raw_title else None
|
||||||
|
else:
|
||||||
|
# If it's a string or other type, reparse from raw_title
|
||||||
|
torrent_item_dict[attr] = parse(raw_title) if raw_title else None
|
||||||
else:
|
else:
|
||||||
torrent_item_dict[attr] = value
|
torrent_item_dict[attr] = value
|
||||||
|
|
||||||
|
|
@ -113,4 +169,4 @@ class TorrentItemModel(Base):
|
||||||
return json.loads(value)
|
return json.loads(value)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@ class APIKeyCreate(BaseModel):
|
||||||
"""Schema for creating a new API key."""
|
"""Schema for creating a new API key."""
|
||||||
name: str = Field(..., description="Name of the API key")
|
name: str = Field(..., description="Name of the API key")
|
||||||
never_expire: bool = Field(False, description="If True, the key will never expire")
|
never_expire: bool = Field(False, description="If True, the key will never expire")
|
||||||
|
proxied_links: bool = Field(False, description="If True, links will be proxied through the server")
|
||||||
|
|
||||||
class APIKeyUpdate(BaseModel):
|
class APIKeyUpdate(BaseModel):
|
||||||
"""Schema for updating an existing API key."""
|
"""Schema for updating an existing API key."""
|
||||||
is_active: Optional[bool] = Field(None, description="Whether the API key is active")
|
is_active: Optional[bool] = Field(None, description="Whether the API key is active")
|
||||||
expiration_date: Optional[datetime] = Field(None, description="New expiration date for the API key")
|
expiration_date: Optional[datetime] = Field(None, description="New expiration date for the API key")
|
||||||
name: Optional[str] = Field(None, description="New name for the API key")
|
name: Optional[str] = Field(None, description="New name for the API key")
|
||||||
|
proxied_links: Optional[bool] = Field(None, description="If True, links will be proxied through the server")
|
||||||
|
|
||||||
class APIKeyInDB(BaseModel):
|
class APIKeyInDB(BaseModel):
|
||||||
"""Schema for API key data as stored in the database."""
|
"""Schema for API key data as stored in the database."""
|
||||||
|
|
@ -24,6 +26,7 @@ class APIKeyInDB(BaseModel):
|
||||||
latest_query_date: Optional[datetime]
|
latest_query_date: Optional[datetime]
|
||||||
total_queries: int
|
total_queries: int
|
||||||
name: str
|
name: str
|
||||||
|
proxied_links: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
from_attributes=True,
|
from_attributes=True,
|
||||||
|
|
@ -37,8 +40,9 @@ class APIKeyOut(BaseModel):
|
||||||
never_expire: bool
|
never_expire: bool
|
||||||
expiration_date: Optional[datetime]
|
expiration_date: Optional[datetime]
|
||||||
name: str
|
name: str
|
||||||
|
proxied_links: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
from_attributes=True,
|
from_attributes=True,
|
||||||
json_encoders={uuid.UUID: str}
|
json_encoders={uuid.UUID: str}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ class DebridService(str, enum.Enum):
|
||||||
AD = "AllDebrid"
|
AD = "AllDebrid"
|
||||||
TB = "TorBox"
|
TB = "TorBox"
|
||||||
PM = "Premiumize"
|
PM = "Premiumize"
|
||||||
|
DL = "Debrid-Link"
|
||||||
|
ED = "EasyDebrid"
|
||||||
|
OC = "Offcloud"
|
||||||
|
PP = "PikPak"
|
||||||
|
|
||||||
|
|
||||||
class NoCacheVideoLanguages(str, enum.Enum):
|
class NoCacheVideoLanguages(str, enum.Enum):
|
||||||
|
|
@ -41,9 +45,9 @@ class NoCacheVideoLanguages(str, enum.Enum):
|
||||||
def get_default_worker_count():
|
def get_default_worker_count():
|
||||||
"""
|
"""
|
||||||
Calculate the default number of workers based on CPU cores.
|
Calculate the default number of workers based on CPU cores.
|
||||||
Returns the number of CPU cores multiplied by 2, with a minimum of 2 and a maximum of 6.
|
Returns the number of CPU cores multiplied by 2, with a minimum of 2 and a maximum of 10.
|
||||||
"""
|
"""
|
||||||
return min(max(multiprocessing.cpu_count() * 2, 2), 6)
|
return min(max(multiprocessing.cpu_count() * 2, 2), 8)
|
||||||
|
|
||||||
|
|
||||||
def check_env_variable(var_name):
|
def check_env_variable(var_name):
|
||||||
|
|
@ -109,6 +113,9 @@ class Settings(BaseSettings):
|
||||||
pm_token: str | None = None
|
pm_token: str | None = None
|
||||||
pm_unique_account: bool = check_env_variable("PM_TOKEN")
|
pm_unique_account: bool = check_env_variable("PM_TOKEN")
|
||||||
pm_base_url: str = "https://www.premiumize.me/api"
|
pm_base_url: str = "https://www.premiumize.me/api"
|
||||||
|
|
||||||
|
# STREMTHRU
|
||||||
|
stremthru_url: str = "https://stremthru.13377001.xyz"
|
||||||
|
|
||||||
# LOGGING
|
# LOGGING
|
||||||
log_level: LogLevel = LogLevel.INFO
|
log_level: LogLevel = LogLevel.INFO
|
||||||
|
|
@ -128,12 +135,14 @@ class Settings(BaseSettings):
|
||||||
pg_pass: str = "streamfusion" # "stremio"
|
pg_pass: str = "streamfusion" # "stremio"
|
||||||
pg_base: str = "streamfusion"
|
pg_base: str = "streamfusion"
|
||||||
pg_echo: bool = False
|
pg_echo: bool = False
|
||||||
|
pg_pool_size: int = 100
|
||||||
|
pg_max_overflow: int = 50
|
||||||
|
|
||||||
# REDIS
|
# REDIS
|
||||||
redis_host: str = "redis"
|
redis_host: str = "redis"
|
||||||
redis_port: int = 6379
|
redis_port: int = 6379
|
||||||
redis_db: int = 5
|
redis_db: int = 5
|
||||||
redis_expiration: int = 604800
|
redis_expiration: int = 604800 # 7 jours
|
||||||
redis_password: str | None = None
|
redis_password: str | None = None
|
||||||
|
|
||||||
# TMDB
|
# TMDB
|
||||||
|
|
@ -149,6 +158,22 @@ class Settings(BaseSettings):
|
||||||
# ZILEAN DMM API
|
# ZILEAN DMM API
|
||||||
zilean_host: str = "zilean"
|
zilean_host: str = "zilean"
|
||||||
zilean_port: int = 8181
|
zilean_port: int = 8181
|
||||||
|
|
||||||
|
# DEBRIDLINK
|
||||||
|
dl_token: str | None = None
|
||||||
|
dl_unique_account: bool = check_env_variable("DL_TOKEN")
|
||||||
|
|
||||||
|
# EASYDEBRID
|
||||||
|
ed_token: str | None = None
|
||||||
|
ed_unique_account: bool = check_env_variable("ED_TOKEN")
|
||||||
|
|
||||||
|
# OFFCLOUD
|
||||||
|
oc_credentials: str | None = None
|
||||||
|
oc_unique_account: bool = check_env_variable("OC_CREDENTIALS")
|
||||||
|
|
||||||
|
# PIKPAK
|
||||||
|
pp_credentials: str | None = None
|
||||||
|
pp_unique_account: bool = check_env_variable("PP_CREDENTIALS")
|
||||||
zilean_schema: str = "http"
|
zilean_schema: str = "http"
|
||||||
zilean_max_workers: int = 4
|
zilean_max_workers: int = 4
|
||||||
zilean_pool_connections: int = 10
|
zilean_pool_connections: int = 10
|
||||||
|
|
@ -167,6 +192,17 @@ class Settings(BaseSettings):
|
||||||
sharewood_passkey: str | None = None
|
sharewood_passkey: str | None = None
|
||||||
sharewood_unique_account: bool = check_env_variable("SHAREWOOD_PASSKEY")
|
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
|
||||||
public_cache_url: str = "https://stremio-jackett-cacher.elfhosted.com/"
|
public_cache_url: str = "https://stremio-jackett-cacher.elfhosted.com/"
|
||||||
|
|
||||||
|
|
@ -260,6 +296,20 @@ class Settings(BaseSettings):
|
||||||
"""
|
"""
|
||||||
return self.no_cache_video_language.value
|
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:
|
try:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@
|
||||||
<th data-sort="apiKey">API Key <span class="sort-icon"></span></th>
|
<th data-sort="apiKey">API Key <span class="sort-icon"></span></th>
|
||||||
<th data-sort="name">Name <span class="sort-icon"></span></th>
|
<th data-sort="name">Name <span class="sort-icon"></span></th>
|
||||||
<th data-sort="status">Status <span class="sort-icon"></span></th>
|
<th data-sort="status">Status <span class="sort-icon"></span></th>
|
||||||
|
<th data-sort="proxified">Proxification <span class="sort-icon"></span></th>
|
||||||
<th data-sort="expiration">Expiration Date <span class="sort-icon"></span></th>
|
<th data-sort="expiration">Expiration Date <span class="sort-icon"></span></th>
|
||||||
<th data-sort="lastUse">Last Use <span class="sort-icon"></span></th>
|
<th data-sort="lastUse">Last Use <span class="sort-icon"></span></th>
|
||||||
<th data-sort="totalUse">Total Use <span class="sort-icon"></span></th>
|
<th data-sort="totalUse">Total Use <span class="sort-icon"></span></th>
|
||||||
|
|
@ -112,6 +113,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td>{{ log.name or 'N/A' }}</td>
|
<td>{{ log.name or 'N/A' }}</td>
|
||||||
<td>{{ 'Active' if log.is_active else 'Inactive' }}</td>
|
<td>{{ 'Active' if log.is_active else 'Inactive' }}</td>
|
||||||
|
<td>{{ 'Enabled' if log.proxied_links else 'Disabled' }}</td>
|
||||||
<td>{{ 'Never' if log.never_expire else log.expiration_date }}</td>
|
<td>{{ 'Never' if log.never_expire else log.expiration_date }}</td>
|
||||||
<td>{{ log.latest_query_date or 'N/A' }}</td>
|
<td>{{ log.latest_query_date or 'N/A' }}</td>
|
||||||
<td>{{ log.total_queries }}</td>
|
<td>{{ log.total_queries }}</td>
|
||||||
|
|
@ -128,6 +130,10 @@
|
||||||
<button type="submit" class="btn btn-secondary btn-sm">Renew</button>
|
<button type="submit" class="btn btn-secondary btn-sm">Renew</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<form action="{{ url_for('toggle_proxied_links')(request) }}" method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="api_key" value="{{ log.api_key }}">
|
||||||
|
<button type="submit" class="btn {% if log.proxied_links %}btn-warning{% else %}btn-secondary{% endif %} btn-sm">{% if log.proxied_links %}Disable{% else %}Enable{% endif %} Proxy</button>
|
||||||
|
</form>
|
||||||
<form action="{{ url_for('delete_api_key')(request) }}" method="post" class="d-inline">
|
<form action="{{ url_for('delete_api_key')(request) }}" method="post" class="d-inline">
|
||||||
<input type="hidden" name="api_key" value="{{ log.api_key }}">
|
<input type="hidden" name="api_key" value="{{ log.api_key }}">
|
||||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete this API key?')">Delete</button>
|
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete this API key?')">Delete</button>
|
||||||
|
|
@ -201,9 +207,10 @@
|
||||||
'apiKey': 1,
|
'apiKey': 1,
|
||||||
'name': 2,
|
'name': 2,
|
||||||
'status': 3,
|
'status': 3,
|
||||||
'expiration': 4,
|
'proxified': 4,
|
||||||
'lastUse': 5,
|
'expiration': 5,
|
||||||
'totalUse': 6
|
'lastUse': 6,
|
||||||
|
'totalUse': 7
|
||||||
};
|
};
|
||||||
return columnMap[column];
|
return columnMap[column];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
<input type="checkbox" class="form-check-input" id="never_expires" name="never_expires">
|
<input type="checkbox" class="form-check-input" id="never_expires" name="never_expires">
|
||||||
<label class="form-check-label" for="never_expires">Never expires</label>
|
<label class="form-check-label" for="never_expires">Never expires</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="proxied_links" name="proxied_links">
|
||||||
|
<label class="form-check-label" for="proxied_links">Activer la proxification des liens vidéos</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Create</button>
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
const sorts = ['quality', 'sizedesc', 'sizeasc', 'qualitythensize'];
|
const sorts = ['quality', 'sizedesc', 'sizeasc', 'qualitythensize'];
|
||||||
const qualityExclusions = ['2160p', '1080p', '720p', '480p', 'rips', 'cam', 'hevc', 'unknown'];
|
const qualityExclusions = ['2160p', '1080p', '720p', '480p', 'rips', 'cam', 'hevc', 'unknown'];
|
||||||
const languages = ['en', 'fr', 'multi'];
|
const languages = ['en', 'fr', 'multi', 'vfq'];
|
||||||
|
|
||||||
|
|
||||||
|
const implementedDebrids = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix'];
|
||||||
|
|
||||||
|
const unimplementedDebrids = ['debrid_dl', 'debrid_ed', 'debrid_oc', 'debrid_pk'];
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
loadData();
|
loadData();
|
||||||
handleUniqueAccounts();
|
handleUniqueAccounts();
|
||||||
updateProviderFields();
|
updateProviderFields();
|
||||||
updateDebridOrderList();
|
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) {
|
function setElementDisplay(elementId, displayStatus) {
|
||||||
|
|
@ -181,7 +198,7 @@ function pollForADCredentials(check, pin, expiresIn) {
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data === null) return; // Skip processing if user hasn't entered PIN yet
|
if (data === null) return;
|
||||||
if (data.data && data.data.activated && data.data.apikey) {
|
if (data.data && data.data.activated && data.data.apikey) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
@ -215,7 +232,7 @@ function resetADAuthButton() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUniqueAccounts() {
|
function handleUniqueAccounts() {
|
||||||
const accounts = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix'];
|
const accounts = ['debrid_rd', 'debrid_ad', 'debrid_tb', 'debrid_pm', 'sharewood', 'yggflix', 'c411', 'torr9'];
|
||||||
|
|
||||||
accounts.forEach(account => {
|
accounts.forEach(account => {
|
||||||
const checkbox = document.getElementById(account);
|
const checkbox = document.getElementById(account);
|
||||||
|
|
@ -253,26 +270,42 @@ function updateDebridOrderList() {
|
||||||
const adEnabled = document.getElementById('debrid_ad').checked || document.getElementById('debrid_ad').disabled;
|
const adEnabled = document.getElementById('debrid_ad').checked || document.getElementById('debrid_ad').disabled;
|
||||||
const tbEnabled = document.getElementById('debrid_tb').checked || document.getElementById('debrid_tb').disabled;
|
const tbEnabled = document.getElementById('debrid_tb').checked || document.getElementById('debrid_tb').disabled;
|
||||||
const pmEnabled = document.getElementById('debrid_pm').checked || document.getElementById('debrid_pm').disabled;
|
const pmEnabled = document.getElementById('debrid_pm').checked || document.getElementById('debrid_pm').disabled;
|
||||||
|
const dlEnabled = document.getElementById('debrid_dl')?.checked || document.getElementById('debrid_dl')?.disabled;
|
||||||
|
const edEnabled = document.getElementById('debrid_ed')?.checked || document.getElementById('debrid_ed')?.disabled;
|
||||||
|
const ocEnabled = document.getElementById('debrid_oc')?.checked || document.getElementById('debrid_oc')?.disabled;
|
||||||
|
const pkEnabled = document.getElementById('debrid_pk')?.checked || document.getElementById('debrid_pk')?.disabled;
|
||||||
|
|
||||||
if (debridOrder.length === 0 ||
|
if (debridOrder.length === 0 ||
|
||||||
!debridOrder.every(service =>
|
!debridOrder.every(service =>
|
||||||
(service === 'Real-Debrid' && rdEnabled) ||
|
(service === 'Real-Debrid' && rdEnabled) ||
|
||||||
(service === 'AllDebrid' && adEnabled) ||
|
(service === 'AllDebrid' && adEnabled) ||
|
||||||
(service === 'TorBox' && tbEnabled) ||
|
(service === 'TorBox' && tbEnabled) ||
|
||||||
(service === 'Premiumize' && pmEnabled)
|
(service === 'Premiumize' && pmEnabled) ||
|
||||||
|
(service === 'Debrid-Link' && dlEnabled) ||
|
||||||
|
(service === 'EasyDebrid' && edEnabled) ||
|
||||||
|
(service === 'Offcloud' && ocEnabled) ||
|
||||||
|
(service === 'PikPak' && pkEnabled)
|
||||||
)) {
|
)) {
|
||||||
debridOrder = [];
|
debridOrder = [];
|
||||||
if (rdEnabled) debridOrder.push('Real-Debrid');
|
if (rdEnabled) debridOrder.push('Real-Debrid');
|
||||||
if (adEnabled) debridOrder.push('AllDebrid');
|
if (adEnabled) debridOrder.push('AllDebrid');
|
||||||
if (tbEnabled) debridOrder.push('TorBox');
|
if (tbEnabled) debridOrder.push('TorBox');
|
||||||
if (pmEnabled) debridOrder.push('Premiumize');
|
if (pmEnabled) debridOrder.push('Premiumize');
|
||||||
|
if (dlEnabled) debridOrder.push('Debrid-Link');
|
||||||
|
if (edEnabled) debridOrder.push('EasyDebrid');
|
||||||
|
if (ocEnabled) debridOrder.push('Offcloud');
|
||||||
|
if (pkEnabled) debridOrder.push('PikPak');
|
||||||
}
|
}
|
||||||
|
|
||||||
debridOrder.forEach(serviceName => {
|
debridOrder.forEach(serviceName => {
|
||||||
if ((serviceName === 'Real-Debrid' && rdEnabled) ||
|
if ((serviceName === 'Real-Debrid' && rdEnabled) ||
|
||||||
(serviceName === 'AllDebrid' && adEnabled) ||
|
(serviceName === 'AllDebrid' && adEnabled) ||
|
||||||
(serviceName === 'Premiumize' && pmEnabled) ||
|
(serviceName === 'Premiumize' && pmEnabled) ||
|
||||||
(serviceName === 'TorBox' && tbEnabled)) {
|
(serviceName === 'TorBox' && tbEnabled) ||
|
||||||
|
(serviceName === 'Debrid-Link' && dlEnabled) ||
|
||||||
|
(serviceName === 'EasyDebrid' && edEnabled) ||
|
||||||
|
(serviceName === 'Offcloud' && ocEnabled) ||
|
||||||
|
(serviceName === 'PikPak' && pkEnabled)) {
|
||||||
addDebridToList(serviceName);
|
addDebridToList(serviceName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -289,6 +322,18 @@ function updateDebridOrderList() {
|
||||||
if (pmEnabled && !debridOrder.includes('Premiumize')) {
|
if (pmEnabled && !debridOrder.includes('Premiumize')) {
|
||||||
addDebridToList('Premiumize');
|
addDebridToList('Premiumize');
|
||||||
}
|
}
|
||||||
|
if (dlEnabled && !debridOrder.includes('Debrid-Link')) {
|
||||||
|
addDebridToList('Debrid-Link');
|
||||||
|
}
|
||||||
|
if (edEnabled && !debridOrder.includes('EasyDebrid')) {
|
||||||
|
addDebridToList('EasyDebrid');
|
||||||
|
}
|
||||||
|
if (ocEnabled && !debridOrder.includes('Offcloud')) {
|
||||||
|
addDebridToList('Offcloud');
|
||||||
|
}
|
||||||
|
if (pkEnabled && !debridOrder.includes('PikPak')) {
|
||||||
|
addDebridToList('PikPak');
|
||||||
|
}
|
||||||
|
|
||||||
Sortable.create(debridOrderList, {
|
Sortable.create(debridOrderList, {
|
||||||
animation: 150,
|
animation: 150,
|
||||||
|
|
@ -330,40 +375,103 @@ function toggleDebridOrderList() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleStremThruFields() {
|
||||||
|
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
|
||||||
|
if (!stremthruEnabledCheckbox) return;
|
||||||
|
|
||||||
|
const isEnabled = stremthruEnabledCheckbox.checked;
|
||||||
|
const urlDiv = document.getElementById('stremthru_url_div');
|
||||||
|
const authDiv = document.getElementById('stremthru_auth_div');
|
||||||
|
const urlInput = document.getElementById('stremthru_url');
|
||||||
|
const defaultUrl = 'https://stremthru.13377001.xyz/';
|
||||||
|
|
||||||
|
if (isEnabled) {
|
||||||
|
setElementDisplay('stremthru_url_div', 'block');
|
||||||
|
if (authDiv) setElementDisplay('stremthru_auth_div', 'block');
|
||||||
|
|
||||||
|
// Set default URL if empty or placeholder
|
||||||
|
if (urlInput && (!urlInput.value || urlInput.value === urlInput.placeholder)) {
|
||||||
|
urlInput.value = defaultUrl;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setElementDisplay('stremthru_url_div', 'none');
|
||||||
|
if (authDiv) setElementDisplay('stremthru_auth_div', 'none');
|
||||||
|
|
||||||
|
// Si StremThru est désactivé, désactiver et décocher les services non implémentés
|
||||||
|
unimplementedDebrids.forEach(id => {
|
||||||
|
const checkbox = document.getElementById(id);
|
||||||
|
if (checkbox && checkbox.checked) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
// Déclencher manuellement la mise à jour pour masquer les champs
|
||||||
|
updateProviderFields();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateDebridDownloaderOptions() {
|
function updateDebridDownloaderOptions() {
|
||||||
const debridDownloaderOptions = document.getElementById('debridDownloaderOptions');
|
const debridDownloaderOptions = document.getElementById('debridDownloaderOptions');
|
||||||
if (!debridDownloaderOptions) return;
|
if (!debridDownloaderOptions) return;
|
||||||
|
|
||||||
debridDownloaderOptions.innerHTML = '';
|
debridDownloaderOptions.innerHTML = '';
|
||||||
|
|
||||||
const rdEnabled = document.getElementById('debrid_rd').checked || document.getElementById('debrid_rd').disabled;
|
// --- Vérifier les services de débridage standard ---
|
||||||
const adEnabled = document.getElementById('debrid_ad').checked || document.getElementById('debrid_ad').disabled;
|
const rdEnabled = document.getElementById('debrid_rd')?.checked || document.getElementById('debrid_rd')?.disabled;
|
||||||
const tbEnabled = document.getElementById('debrid_tb').checked || document.getElementById('debrid_tb').disabled;
|
const adEnabled = document.getElementById('debrid_ad')?.checked || document.getElementById('debrid_ad')?.disabled;
|
||||||
const pmEnabled = document.getElementById('debrid_pm').checked || document.getElementById('debrid_pm').disabled;
|
const tbEnabled = document.getElementById('debrid_tb')?.checked || document.getElementById('debrid_tb')?.disabled;
|
||||||
|
const pmEnabled = document.getElementById('debrid_pm')?.checked || document.getElementById('debrid_pm')?.disabled;
|
||||||
|
const dlEnabled = document.getElementById('debrid_dl')?.checked || document.getElementById('debrid_dl')?.disabled;
|
||||||
|
const edEnabled = document.getElementById('debrid_ed')?.checked || document.getElementById('debrid_ed')?.disabled;
|
||||||
|
const ocEnabled = document.getElementById('debrid_oc')?.checked || document.getElementById('debrid_oc')?.disabled;
|
||||||
|
const pkEnabled = document.getElementById('debrid_pk')?.checked || document.getElementById('debrid_pk')?.disabled;
|
||||||
|
|
||||||
|
// --- Vérifier StremThru ---
|
||||||
|
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
|
||||||
|
const stremthruEnabled = stremthruEnabledCheckbox ? stremthruEnabledCheckbox.checked : false;
|
||||||
|
|
||||||
let firstOption = null;
|
let firstOption = null;
|
||||||
|
|
||||||
|
// --- Ajouter des options en fonction des services activés ---
|
||||||
if (rdEnabled) {
|
if (rdEnabled) {
|
||||||
firstOption = addDebridDownloaderOption('Real-Debrid');
|
firstOption = addDebridDownloaderOption('Real-Debrid');
|
||||||
}
|
}
|
||||||
if (adEnabled) {
|
if (adEnabled) {
|
||||||
firstOption = addDebridDownloaderOption('AllDebrid');
|
// Utiliser l'opérateur ternaire pour une attribution plus propre
|
||||||
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('AllDebrid');
|
||||||
|
if (firstOption.value !== 'AllDebrid') addDebridDownloaderOption('AllDebrid');
|
||||||
}
|
}
|
||||||
if (tbEnabled) {
|
if (tbEnabled) {
|
||||||
if (!firstOption) {
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('TorBox');
|
||||||
firstOption = addDebridDownloaderOption('TorBox');
|
if (firstOption.value !== 'TorBox') addDebridDownloaderOption('TorBox');
|
||||||
} else {
|
|
||||||
addDebridDownloaderOption('TorBox');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (pmEnabled) {
|
if (pmEnabled) {
|
||||||
if (!firstOption) {
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('Premiumize');
|
||||||
firstOption = addDebridDownloaderOption('Premiumize');
|
if (firstOption.value !== 'Premiumize') addDebridDownloaderOption('Premiumize');
|
||||||
} else {
|
}
|
||||||
addDebridDownloaderOption('Premiumize');
|
if (dlEnabled) {
|
||||||
}
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('Debrid-Link');
|
||||||
|
if (firstOption.value !== 'Debrid-Link') addDebridDownloaderOption('Debrid-Link');
|
||||||
|
}
|
||||||
|
if (edEnabled) {
|
||||||
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('EasyDebrid');
|
||||||
|
if (firstOption.value !== 'EasyDebrid') addDebridDownloaderOption('EasyDebrid');
|
||||||
|
}
|
||||||
|
if (ocEnabled) {
|
||||||
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('Offcloud');
|
||||||
|
if (firstOption.value !== 'Offcloud') addDebridDownloaderOption('Offcloud');
|
||||||
|
}
|
||||||
|
if (pkEnabled) {
|
||||||
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('PikPak');
|
||||||
|
if (firstOption.value !== 'PikPak') addDebridDownloaderOption('PikPak');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ajouter StremThru si activé ---
|
||||||
|
if (stremthruEnabled) {
|
||||||
|
firstOption = firstOption ? firstOption : addDebridDownloaderOption('StremThru');
|
||||||
|
if (firstOption.value !== 'StremThru') addDebridDownloaderOption('StremThru');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sélectionner la première option ajoutée par défaut si aucune n'est sélectionnée
|
||||||
if (firstOption && !document.querySelector('input[name="debrid_downloader"]:checked')) {
|
if (firstOption && !document.querySelector('input[name="debrid_downloader"]:checked')) {
|
||||||
firstOption.checked = true;
|
firstOption.checked = true;
|
||||||
}
|
}
|
||||||
|
|
@ -397,33 +505,94 @@ function addDebridDownloaderOption(serviceName) {
|
||||||
|
|
||||||
|
|
||||||
function updateProviderFields() {
|
function updateProviderFields() {
|
||||||
const RDdebridChecked = document.getElementById('debrid_rd').checked ||
|
console.log("--- Running updateProviderFields ---"); // Debug start
|
||||||
document.getElementById('debrid_rd').disabled;
|
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
|
||||||
const ADdebridChecked = document.getElementById('debrid_ad').checked ||
|
let stremthruWasEnabled = stremthruEnabledCheckbox ? stremthruEnabledCheckbox.checked : false; // Track initial state
|
||||||
document.getElementById('debrid_ad').disabled;
|
let stremthruForcedEnable = false;
|
||||||
const TBdebridChecked = document.getElementById('debrid_tb').checked ||
|
let anyUnimplementedChecked = false; // Flag to track if any unimplemented service is checked
|
||||||
document.getElementById('debrid_tb').disabled;
|
|
||||||
const PMdebridChecked = document.getElementById('debrid_pm').checked ||
|
const serviceStates = {};
|
||||||
document.getElementById('debrid_pm').disabled;
|
const allDebrids = [...implementedDebrids, ...unimplementedDebrids];
|
||||||
|
|
||||||
|
// Vérifier l'état des autres éléments de l'interface
|
||||||
const cacheChecked = document.getElementById('cache')?.checked;
|
const cacheChecked = document.getElementById('cache')?.checked;
|
||||||
const yggflixChecked = document.getElementById('yggflix')?.checked ||
|
const yggflixChecked = document.getElementById('yggflix')?.checked || document.getElementById('yggflix')?.disabled;
|
||||||
document.getElementById('yggflix')?.disabled;
|
const sharewoodChecked = document.getElementById('sharewood')?.checked || document.getElementById('sharewood')?.disabled;
|
||||||
const sharewoodChecked = document.getElementById('sharewood')?.checked ||
|
const torboxChecked = document.getElementById('debrid_tb')?.checked || document.getElementById('debrid_tb')?.disabled;
|
||||||
document.getElementById('sharewood')?.disabled;
|
const c411Checked = document.getElementById('c411')?.checked || document.getElementById('c411')?.disabled;
|
||||||
|
const torr9Checked = document.getElementById('torr9')?.checked || document.getElementById('torr9')?.disabled;
|
||||||
|
|
||||||
setElementDisplay('rd_debrid-fields', RDdebridChecked ? 'block' : 'none');
|
// Afficher/masquer les champs spécifiques
|
||||||
setElementDisplay('ad_debrid-fields', ADdebridChecked ? 'block' : 'none');
|
|
||||||
setElementDisplay('tb_debrid-fields', TBdebridChecked ? 'block' : 'none');
|
|
||||||
setElementDisplay('pm_debrid-fields', PMdebridChecked ? 'block' : 'none');
|
|
||||||
setElementDisplay('cache-fields', cacheChecked ? 'block' : 'none');
|
setElementDisplay('cache-fields', cacheChecked ? 'block' : 'none');
|
||||||
setElementDisplay('ygg-fields', yggflixChecked ? 'block' : 'none');
|
|
||||||
setElementDisplay('sharewood-fields', sharewoodChecked ? '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');
|
||||||
|
|
||||||
|
// Traiter tous les débrideurs
|
||||||
|
allDebrids.forEach(id => {
|
||||||
|
const checkbox = document.getElementById(id);
|
||||||
|
if (!checkbox) return;
|
||||||
|
const isChecked = checkbox.checked || checkbox.disabled;
|
||||||
|
serviceStates[id] = isChecked;
|
||||||
|
|
||||||
|
// Déterminer l'ID du div de credentials correspondant
|
||||||
|
let credDivId = '';
|
||||||
|
switch (id) {
|
||||||
|
case 'debrid_rd': credDivId = 'rd_token_info_div'; break;
|
||||||
|
case 'debrid_ad': credDivId = 'ad_token_info_div'; break;
|
||||||
|
case 'debrid_pm': credDivId = 'pm_token_info_div'; break;
|
||||||
|
case 'debrid_tb': credDivId = 'tb_token_info_div'; break;
|
||||||
|
case 'debrid_dl': credDivId = 'debridlink_api_key_div'; break;
|
||||||
|
case 'debrid_ed': credDivId = 'easydebrid_api_key_div'; break;
|
||||||
|
case 'debrid_oc': credDivId = 'offcloud_credentials_div'; break;
|
||||||
|
case 'debrid_pk': credDivId = 'pikpak_credentials_div'; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher/masquer le div de credentials
|
||||||
|
if (credDivId) {
|
||||||
|
setElementDisplay(credDivId, isChecked ? 'block' : 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logique pour forcer l'activation de StremThru avec les débrideurs non implémentés
|
||||||
|
if (unimplementedDebrids.includes(id) && isChecked) {
|
||||||
|
if (stremthruEnabledCheckbox && !stremthruEnabledCheckbox.checked) {
|
||||||
|
stremthruEnabledCheckbox.checked = true;
|
||||||
|
stremthruForcedEnable = true; // Marquer qu'on l'a forcé
|
||||||
|
}
|
||||||
|
anyUnimplementedChecked = true; // Définir le drapeau
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gérer l'état de la case à cocher StremThru: désactiver si un service non implémenté est coché
|
||||||
|
if (stremthruEnabledCheckbox) {
|
||||||
|
if (anyUnimplementedChecked) {
|
||||||
|
stremthruEnabledCheckbox.checked = true; // S'assurer qu'elle est cochée
|
||||||
|
stremthruEnabledCheckbox.disabled = true; // Désactiver la case à cocher
|
||||||
|
} else {
|
||||||
|
stremthruEnabledCheckbox.disabled = false; // Réactiver si aucun service non implémenté n'est coché
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afficher/masquer les champs StremThru
|
||||||
|
setElementDisplay('stremthru_url_div', stremthruEnabledCheckbox.checked ? 'block' : 'none');
|
||||||
|
const authDiv = document.getElementById('stremthru_auth_div');
|
||||||
|
if (authDiv) {
|
||||||
|
setElementDisplay('stremthru_auth_div', stremthruEnabledCheckbox.checked ? 'block' : 'none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si on a forcé l'activation de StremThru OU si son état a changé, mettre à jour la visibilité de ses champs
|
||||||
|
if (stremthruEnabledCheckbox && (stremthruForcedEnable || stremthruEnabledCheckbox.checked !== stremthruWasEnabled || anyUnimplementedChecked)) {
|
||||||
|
toggleStremThruFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gérer l'ordre des débrideurs
|
||||||
const debridOrderCheckbox = document.getElementById('debrid_order');
|
const debridOrderCheckbox = document.getElementById('debrid_order');
|
||||||
const debridOrderList = document.getElementById('debridOrderList');
|
const debridOrderList = document.getElementById('debridOrderList');
|
||||||
|
|
||||||
if (debridOrderCheckbox && debridOrderList) {
|
if (debridOrderCheckbox && debridOrderList) {
|
||||||
const anyDebridEnabled = RDdebridChecked || ADdebridChecked || TBdebridChecked || PMdebridChecked;
|
// Vérifier si au moins un débrideur est activé
|
||||||
|
const anyDebridEnabled = Object.values(serviceStates).some(state => state);
|
||||||
|
|
||||||
debridOrderCheckbox.disabled = !anyDebridEnabled;
|
debridOrderCheckbox.disabled = !anyDebridEnabled;
|
||||||
|
|
||||||
|
|
@ -434,26 +603,57 @@ function updateProviderFields() {
|
||||||
debridOrderList.classList.toggle('hidden', !(anyDebridEnabled && debridOrderCheckbox.checked));
|
debridOrderList.classList.toggle('hidden', !(anyDebridEnabled && debridOrderCheckbox.checked));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les listes et options
|
||||||
updateDebridOrderList();
|
updateDebridOrderList();
|
||||||
updateDebridDownloaderOptions();
|
updateDebridDownloaderOptions();
|
||||||
|
|
||||||
ensureDebridConsistency();
|
ensureDebridConsistency();
|
||||||
|
console.log("--- Finished updateProviderFields ---"); // Debug end
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDebridConsistency() {
|
function ensureDebridConsistency() {
|
||||||
const RDdebridChecked = document.getElementById('debrid_rd').checked;
|
// Récupérer l'état de tous les débrideurs
|
||||||
const ADdebridChecked = document.getElementById('debrid_ad').checked;
|
const serviceStates = {};
|
||||||
const TBdebridChecked = document.getElementById('debrid_tb').checked;
|
const allDebrids = [...implementedDebrids, ...unimplementedDebrids];
|
||||||
const PMdebridChecked = document.getElementById('debrid_pm').checked;
|
let anyDebridChecked = false;
|
||||||
const debridOrderChecked = document.getElementById('debrid_order').checked;
|
let anyUnimplementedChecked = false;
|
||||||
|
|
||||||
if (!RDdebridChecked && !ADdebridChecked && !TBdebridChecked && !PMdebridChecked) {
|
allDebrids.forEach(id => {
|
||||||
document.getElementById('debrid_order').checked = false;
|
const checkbox = document.getElementById(id);
|
||||||
document.getElementById('debridOrderList').classList.add('hidden');
|
if (!checkbox) return;
|
||||||
|
const isChecked = checkbox.checked;
|
||||||
|
serviceStates[id] = isChecked;
|
||||||
|
if (isChecked) {
|
||||||
|
anyDebridChecked = true;
|
||||||
|
if (unimplementedDebrids.includes(id)) {
|
||||||
|
anyUnimplementedChecked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gérer l'état de la case à cocher d'ordre des débrideurs
|
||||||
|
const debridOrderCheckbox = document.getElementById('debrid_order');
|
||||||
|
const debridOrderList = document.getElementById('debridOrderList');
|
||||||
|
|
||||||
|
if (debridOrderCheckbox && debridOrderList) {
|
||||||
|
if (!anyDebridChecked) {
|
||||||
|
debridOrderCheckbox.checked = false;
|
||||||
|
debridOrderList.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debridOrderCheckbox.checked && !anyDebridChecked) {
|
||||||
|
debridOrderCheckbox.checked = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debridOrderChecked && !RDdebridChecked && !ADdebridChecked && !TBdebridChecked && !PMdebridChecked) {
|
// Gérer l'état de la case à cocher StremThru
|
||||||
document.getElementById('debrid_order').checked = false;
|
const stremthruEnabledCheckbox = document.getElementById('stremthru_enabled');
|
||||||
|
if (stremthruEnabledCheckbox) {
|
||||||
|
if (anyUnimplementedChecked) {
|
||||||
|
stremthruEnabledCheckbox.checked = true;
|
||||||
|
stremthruEnabledCheckbox.disabled = true;
|
||||||
|
} else {
|
||||||
|
stremthruEnabledCheckbox.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDebridDownloaderOptions();
|
updateDebridDownloaderOptions();
|
||||||
|
|
@ -489,7 +689,7 @@ function loadData() {
|
||||||
zilean: true,
|
zilean: true,
|
||||||
yggflix: true,
|
yggflix: true,
|
||||||
sharewood: false,
|
sharewood: false,
|
||||||
maxSize: '18',
|
maxSize: '150',
|
||||||
resultsPerQuality: '10',
|
resultsPerQuality: '10',
|
||||||
maxResults: '30',
|
maxResults: '30',
|
||||||
minCachedResults: '10',
|
minCachedResults: '10',
|
||||||
|
|
@ -497,8 +697,8 @@ function loadData() {
|
||||||
ctg_yggtorrent: true,
|
ctg_yggtorrent: true,
|
||||||
ctg_yggflix: false,
|
ctg_yggflix: false,
|
||||||
metadataProvider: 'tmdb',
|
metadataProvider: 'tmdb',
|
||||||
sort: 'qualitythensize',
|
sort: 'quality',
|
||||||
exclusion: ['cam', '2160p'],
|
exclusion: ['cam'],
|
||||||
languages: ['fr', 'multi'],
|
languages: ['fr', 'multi'],
|
||||||
debrid_rd: false,
|
debrid_rd: false,
|
||||||
debrid_ad: false,
|
debrid_ad: false,
|
||||||
|
|
@ -506,7 +706,9 @@ function loadData() {
|
||||||
debrid_pm: false,
|
debrid_pm: false,
|
||||||
tb_usenet: false,
|
tb_usenet: false,
|
||||||
tb_search: false,
|
tb_search: false,
|
||||||
debrid_order: false
|
debrid_order: false,
|
||||||
|
c411: true,
|
||||||
|
torr9: true
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(defaultConfig).forEach(key => {
|
Object.keys(defaultConfig).forEach(key => {
|
||||||
|
|
@ -547,6 +749,8 @@ function loadData() {
|
||||||
setElementValue('pm_token_info', decodedData.PMToken, '');
|
setElementValue('pm_token_info', decodedData.PMToken, '');
|
||||||
setElementValue('sharewoodPasskey', decodedData.sharewoodPasskey, '');
|
setElementValue('sharewoodPasskey', decodedData.sharewoodPasskey, '');
|
||||||
setElementValue('yggPasskey', decodedData.yggPasskey, '');
|
setElementValue('yggPasskey', decodedData.yggPasskey, '');
|
||||||
|
setElementValue('c411ApiKey', decodedData.c411ApiKey, '');
|
||||||
|
setElementValue('torr9ApiKey', decodedData.torr9ApiKey, '');
|
||||||
setElementValue('ApiKey', decodedData.apiKey, '');
|
setElementValue('ApiKey', decodedData.apiKey, '');
|
||||||
setElementValue('exclusion-keywords', (decodedData.exclusionKeywords || []).join(', '), '');
|
setElementValue('exclusion-keywords', (decodedData.exclusionKeywords || []).join(', '), '');
|
||||||
|
|
||||||
|
|
@ -569,10 +773,50 @@ function loadData() {
|
||||||
ensureDebridConsistency();
|
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) {
|
function getLink(method) {
|
||||||
|
const apiKey = document.getElementById('ApiKey').value;
|
||||||
|
|
||||||
|
// Vérifier l'API key en premier
|
||||||
|
if (!validateApiKey(apiKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
addonHost: new URL(window.location.href).origin,
|
addonHost: new URL(window.location.href).origin,
|
||||||
apiKey: document.getElementById('ApiKey').value,
|
apiKey: apiKey,
|
||||||
service: [],
|
service: [],
|
||||||
RDToken: document.getElementById('rd_token_info')?.value,
|
RDToken: document.getElementById('rd_token_info')?.value,
|
||||||
ADToken: document.getElementById('ad_token_info')?.value,
|
ADToken: document.getElementById('ad_token_info')?.value,
|
||||||
|
|
@ -581,6 +825,10 @@ function getLink(method) {
|
||||||
TBUsenet: document.getElementById('tb_usenet')?.checked,
|
TBUsenet: document.getElementById('tb_usenet')?.checked,
|
||||||
TBSearch: document.getElementById('tb_search')?.checked,
|
TBSearch: document.getElementById('tb_search')?.checked,
|
||||||
sharewoodPasskey: document.getElementById('sharewoodPasskey')?.value,
|
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,
|
maxSize: parseInt(document.getElementById('maxSize').value) || 16,
|
||||||
exclusionKeywords: document.getElementById('exclusion-keywords').value.split(',').map(keyword => keyword.trim()).filter(keyword => keyword !== ''),
|
exclusionKeywords: document.getElementById('exclusion-keywords').value.split(',').map(keyword => keyword.trim()).filter(keyword => keyword !== ''),
|
||||||
languages: languages.filter(lang => document.getElementById(lang).checked),
|
languages: languages.filter(lang => document.getElementById(lang).checked),
|
||||||
|
|
@ -598,10 +846,18 @@ function getLink(method) {
|
||||||
yggtorrentCtg: document.getElementById('ctg_yggtorrent')?.checked,
|
yggtorrentCtg: document.getElementById('ctg_yggtorrent')?.checked,
|
||||||
yggflixCtg: document.getElementById('ctg_yggflix')?.checked,
|
yggflixCtg: document.getElementById('ctg_yggflix')?.checked,
|
||||||
yggPasskey: document.getElementById('yggPasskey')?.value,
|
yggPasskey: document.getElementById('yggPasskey')?.value,
|
||||||
torrenting: document.getElementById('torrenting').checked,
|
torrenting: false,
|
||||||
debrid: false,
|
debrid: false,
|
||||||
metadataProvider: document.getElementById('tmdb').checked ? 'tmdb' : 'cinemeta',
|
metadataProvider: document.getElementById('tmdb').checked ? 'tmdb' : 'cinemeta',
|
||||||
debridDownloader: document.querySelector('input[name="debrid_downloader"]:checked')?.value
|
debridDownloader: document.querySelector('input[name="debrid_downloader"]:checked')?.value,
|
||||||
|
// StremThru configuration
|
||||||
|
stremthru: document.getElementById('stremthru_enabled')?.checked || false,
|
||||||
|
stremthruUrl: document.getElementById('stremthru_url')?.value || 'https://stremthru.13377001.xyz',
|
||||||
|
// Nouveaux débrideurs
|
||||||
|
debridlinkApiKey: document.getElementById('debridlink_api_key')?.value || '',
|
||||||
|
easydebridApiKey: document.getElementById('easydebrid_api_key')?.value || '',
|
||||||
|
offcloudCredentials: document.getElementById('offcloud_credentials')?.value || '',
|
||||||
|
pikpakCredentials: document.getElementById('pikpak_credentials')?.value || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
data.service = Array.from(document.getElementById('debridOrderList').children).map(li => li.dataset.serviceName);
|
data.service = Array.from(document.getElementById('debridOrderList').children).map(li => li.dataset.serviceName);
|
||||||
|
|
@ -614,9 +870,15 @@ function getLink(method) {
|
||||||
if (data.service.includes('AllDebrid') && document.getElementById('ad_token_info') && !data.ADToken) missingRequiredFields.push("AllDebrid Account Connection");
|
if (data.service.includes('AllDebrid') && document.getElementById('ad_token_info') && !data.ADToken) missingRequiredFields.push("AllDebrid Account Connection");
|
||||||
if (data.service.includes('TorBox') && document.getElementById('tb_token_info') && !data.TBToken) missingRequiredFields.push("TorBox Account Connection");
|
if (data.service.includes('TorBox') && document.getElementById('tb_token_info') && !data.TBToken) missingRequiredFields.push("TorBox Account Connection");
|
||||||
if (data.service.includes('Premiumize') && document.getElementById('pm_token_info') && !data.PMToken) missingRequiredFields.push("Premiumize Account Connection");
|
if (data.service.includes('Premiumize') && document.getElementById('pm_token_info') && !data.PMToken) missingRequiredFields.push("Premiumize Account Connection");
|
||||||
|
if (data.service.includes('Debrid-Link') && document.getElementById('debridlink_api_key') && !data.debridlinkApiKey) missingRequiredFields.push("Debrid-Link API Key");
|
||||||
|
if (data.service.includes('EasyDebrid') && document.getElementById('easydebrid_api_key') && !data.easydebridApiKey) missingRequiredFields.push("EasyDebrid API Key");
|
||||||
|
if (data.service.includes('Offcloud') && document.getElementById('offcloud_credentials') && !data.offcloudCredentials) missingRequiredFields.push("Offcloud Credentials");
|
||||||
|
if (data.service.includes('PikPak') && document.getElementById('pikpak_credentials') && !data.pikpakCredentials) missingRequiredFields.push("PikPak Credentials");
|
||||||
if (data.languages.length === 0) missingRequiredFields.push("Languages");
|
if (data.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.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) {
|
if (missingRequiredFields.length > 0) {
|
||||||
alert(`Please fill all required fields: ${missingRequiredFields.join(", ")}`);
|
alert(`Please fill all required fields: ${missingRequiredFields.join(", ")}`);
|
||||||
|
|
@ -627,10 +889,6 @@ function getLink(method) {
|
||||||
return /^[a-zA-Z0-9]{32}$/.test(passkey);
|
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)) {
|
if (data.yggflix && data.yggPasskey && !validatePasskey(data.yggPasskey)) {
|
||||||
alert('Ygg Passkey doit contenir exactement 32 caractères alphanumériques');
|
alert('Ygg Passkey doit contenir exactement 32 caractères alphanumériques');
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -641,11 +899,6 @@ function getLink(method) {
|
||||||
return false;
|
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 encodedData = btoa(JSON.stringify(data));
|
||||||
const stremio_link = `${window.location.host}/${encodedData}/manifest.json`;
|
const stremio_link = `${window.location.host}/${encodedData}/manifest.json`;
|
||||||
|
|
||||||
|
|
@ -667,3 +920,33 @@ function showCheckboxes() {
|
||||||
checkboxes.style.display = showLanguageCheckBoxes ? "block" : "none";
|
checkboxes.style.display = showLanguageCheckBoxes ? "block" : "none";
|
||||||
showLanguageCheckBoxes = !showLanguageCheckBoxes;
|
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,6 +51,9 @@
|
||||||
<label for="ApiKey" class="block text-sm font-medium leading-6 text-red-600 text-center">API Key</label>
|
<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"
|
<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">
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -70,13 +73,27 @@
|
||||||
<input id="debrid_rd" name="debrid_rd" type="checkbox"
|
<input id="debrid_rd" name="debrid_rd" type="checkbox"
|
||||||
data-unique-account="{{ rd_unique_account|lower }}"
|
data-unique-account="{{ rd_unique_account|lower }}"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
onchange="updateProviderFields(true)">
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="debrid_rd" class="font-medium text-white">Enable Real-Debrid Service</label>
|
<label for="debrid_rd" class="font-medium text-white">Enable Real-Debrid Service</label>
|
||||||
<p class="text-gray-500 dark:text-gray-300">Activate Real-Débrid service for streaming.
|
<p class="text-gray-500 dark:text-gray-300">Activate Real-Débrid service for streaming.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- RealDebrid Token Info -->
|
||||||
|
<div id="rd_token_info_div" class="mt-4 space-y-3" style="display: none;">
|
||||||
|
<p class="text-sm text-gray-400">Real-Debrid nécessite une authentification OAuth.</p>
|
||||||
|
<button type="button" id="rd-auth-button" onclick="startRealDebridAuth()"
|
||||||
|
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 transition ease-in-out duration-150">
|
||||||
|
S'authentifier avec Real-Debrid
|
||||||
|
</button>
|
||||||
|
<div id="auth-instructions" style="display: none;" class="text-sm text-gray-300">
|
||||||
|
<p>Veuillez visiter <a id="verification-url" href="#" target="_blank"
|
||||||
|
class="font-medium text-indigo-400 hover:text-indigo-300">l'URL de vérification</a>
|
||||||
|
et entrer le code: <strong id="user-code" class="text-white"></strong></p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="rd_token_info" name="rd_token_info">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative flex gap-x-3">
|
<div class="relative flex gap-x-3">
|
||||||
|
|
@ -84,13 +101,27 @@
|
||||||
<input id="debrid_ad" name="debrid_ad" type="checkbox"
|
<input id="debrid_ad" name="debrid_ad" type="checkbox"
|
||||||
data-unique-account="{{ ad_unique_account|lower }}"
|
data-unique-account="{{ ad_unique_account|lower }}"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
onchange="updateProviderFields(true)">
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="debrid_ad" class="font-medium text-white">Enable AllDebrid Service</label>
|
<label for="debrid_ad" class="font-medium text-white">Enable AllDebrid Service</label>
|
||||||
<p class="text-gray-500 dark:text-gray-300">Activate AllDebrid service for streaming.
|
<p class="text-gray-500 dark:text-gray-300">Activate AllDebrid service for streaming.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- AllDebrid Token Info -->
|
||||||
|
<div id="ad_token_info_div" class="mt-4 space-y-3" style="display: none;">
|
||||||
|
<p class="text-sm text-gray-400">AllDebrid nécessite une authentification OAuth.</p>
|
||||||
|
<button type="button" id="ad-auth-button" onclick="startADAuth()"
|
||||||
|
class="rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600 transition ease-in-out duration-150">
|
||||||
|
Connect with AllDebrid
|
||||||
|
</button>
|
||||||
|
<div id="ad-auth-instructions" style="display: none;" class="text-sm text-gray-300">
|
||||||
|
<p>Please visit <a id="ad-verification-url" href="#" target="_blank"
|
||||||
|
class="font-medium text-orange-400 hover:text-orange-300">verification URL</a>
|
||||||
|
and enter the code: <strong id="ad-user-code" class="text-white"></strong></p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="ad_token_info" name="ad_token_info">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative flex gap-x-3">
|
<div class="relative flex gap-x-3">
|
||||||
|
|
@ -98,12 +129,22 @@
|
||||||
<input id="debrid_pm" name="debrid_pm" type="checkbox"
|
<input id="debrid_pm" name="debrid_pm" type="checkbox"
|
||||||
data-unique-account="{{ pm_unique_account|lower }}"
|
data-unique-account="{{ pm_unique_account|lower }}"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
onchange="updateProviderFields(true)">
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="debrid_pm" class="font-medium text-white">Enable Premiumize Service</label>
|
<label for="debrid_pm" class="font-medium text-white">Enable Premiumize Service</label>
|
||||||
<p class="text-gray-500 dark:text-gray-300">Activate Premiumize service for streaming.</p>
|
<p class="text-gray-500 dark:text-gray-300">Activate Premiumize service for streaming.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Premiumize Token Info -->
|
||||||
|
<div id="pm_token_info_div" class="mt-2" style="display: none;">
|
||||||
|
<label for="pm_token_info" class="block text-sm font-medium leading-6 text-white">Premiumize
|
||||||
|
API Key</label>
|
||||||
|
<input type="text" name="pm_token_info" id="pm_token_info"
|
||||||
|
placeholder="Votre clé API Premiumize"
|
||||||
|
class="mt-1 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
||||||
|
<p class="mt-1 text-xs text-gray-400">Trouvez votre clé API dans les paramètres de votre compte
|
||||||
|
Premiumize.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative flex gap-x-3">
|
<div class="relative flex gap-x-3">
|
||||||
|
|
@ -111,27 +152,131 @@
|
||||||
<input id="debrid_tb" name="debrid_tb" type="checkbox"
|
<input id="debrid_tb" name="debrid_tb" type="checkbox"
|
||||||
data-unique-account="{{ tb_unique_account|lower }}"
|
data-unique-account="{{ tb_unique_account|lower }}"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
onchange="updateProviderFields(true)">
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="debrid_tb" class="font-medium text-white">Enable TorBox Service</label>
|
<label for="debrid_tb" class="font-medium text-white">Enable TorBox Service</label>
|
||||||
<p class="text-gray-500 dark:text-gray-300">Activate TorBox service for streaming.
|
<p class="text-gray-500 dark:text-gray-300">Activate TorBox service for streaming.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- TorBox Token Info -->
|
||||||
|
<div id="tb_token_info_div" class="mt-2" style="display: none;">
|
||||||
|
<label for="tb_token_info" class="block text-sm font-medium leading-6 text-white">TorBox API
|
||||||
|
Key</label>
|
||||||
|
<input type="text" name="tb_token_info" id="tb_token_info" placeholder="Votre clé API TorBox"
|
||||||
|
class="mt-1 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
||||||
|
<p class="mt-1 text-xs text-gray-400">Disponible dans les paramètres de votre compte TorBox.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative flex gap-x-3">
|
<div class="relative flex gap-x-3">
|
||||||
<div class="flex h-6 items-center">
|
<div class="flex h-6 items-center">
|
||||||
<input id="torrenting" name="torrenting" type="checkbox"
|
<input id="debrid_dl" name="debrid_dl" type="checkbox"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="torrenting" class="font-medium text-white">Enable Torrenting</label>
|
<label for="debrid_dl" class="font-medium text-white">Enable Debrid-Link Service</label>
|
||||||
<p class="text-gray-500 dark:text-gray-300"><span
|
<p class="text-gray-500 dark:text-gray-300">Activate Debrid-Link service for streaming.</p>
|
||||||
class="font-bold text-red-600 dark:text-red-500">Warning: This may be illegal in
|
|
||||||
some countries. Use at your own risk.</span></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="debridlink_api_key_div" class="mt-2 hidden">
|
||||||
|
<label for="debridlink_api_key" class="block text-sm font-medium leading-6 text-white">Debrid-Link API Key</label>
|
||||||
|
<input type="text" name="debridlink_api_key" id="debridlink_api_key"
|
||||||
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="Enter your Debrid-Link API Key">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex gap-x-3">
|
||||||
|
<div class="flex h-6 items-center">
|
||||||
|
<input id="debrid_ed" name="debrid_ed" type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
|
onchange="updateProviderFields()">
|
||||||
|
</div>
|
||||||
|
<div class="text-sm leading-6">
|
||||||
|
<label for="debrid_ed" class="font-medium text-white">Enable EasyDebrid Service</label>
|
||||||
|
<p class="text-gray-500 dark:text-gray-300">Activate EasyDebrid service for streaming.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="easydebrid_api_key_div" class="mt-2 hidden">
|
||||||
|
<label for="easydebrid_api_key" class="block text-sm font-medium leading-6 text-white">EasyDebrid API Key</label>
|
||||||
|
<input type="text" name="easydebrid_api_key" id="easydebrid_api_key"
|
||||||
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="Enter your EasyDebrid API Key">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex gap-x-3">
|
||||||
|
<div class="flex h-6 items-center">
|
||||||
|
<input id="debrid_oc" name="debrid_oc" type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
|
onchange="updateProviderFields()">
|
||||||
|
</div>
|
||||||
|
<div class="text-sm leading-6">
|
||||||
|
<label for="debrid_oc" class="font-medium text-white">Enable Offcloud Service</label>
|
||||||
|
<p class="text-gray-500 dark:text-gray-300">Activate Offcloud service for streaming.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="offcloud_credentials_div" class="mt-2 hidden">
|
||||||
|
<label for="offcloud_credentials" class="block text-sm font-medium leading-6 text-white">Offcloud Credentials (email:password)</label>
|
||||||
|
<input type="text" name="offcloud_credentials" id="offcloud_credentials"
|
||||||
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="Enter your Offcloud email:password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex gap-x-3">
|
||||||
|
<div class="flex h-6 items-center">
|
||||||
|
<input id="debrid_pk" name="debrid_pk" type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
|
onchange="updateProviderFields()">
|
||||||
|
</div>
|
||||||
|
<div class="text-sm leading-6">
|
||||||
|
<label for="debrid_pk" class="font-medium text-white">Enable PikPak Service</label>
|
||||||
|
<p class="text-gray-500 dark:text-gray-300">Activate PikPak service for streaming.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pikpak_credentials_div" class="mt-2 hidden">
|
||||||
|
<label for="pikpak_credentials" class="block text-sm font-medium leading-6 text-white">PikPak Credentials (email:password)</label>
|
||||||
|
<input type="text" name="pikpak_credentials" id="pikpak_credentials"
|
||||||
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="Enter your PikPak email:password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- StremThru Section -->
|
||||||
|
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<h2 class="text-white font-semibold leading-7">StremThru Settings</h2>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Configure StremThru as an optional interface.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10 space-y-10">
|
||||||
|
<!-- Enable StremThru Checkbox -->
|
||||||
|
<div class="relative flex gap-x-3">
|
||||||
|
<div class="flex h-6 items-center">
|
||||||
|
<input id="stremthru_enabled" name="stremthru_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" onchange="toggleStremThruFields()">
|
||||||
|
</div>
|
||||||
|
<div class="text-sm leading-6">
|
||||||
|
<label for="stremthru_enabled" class="font-medium text-white">Enable StremThru</label>
|
||||||
|
<p class="text-gray-500 dark:text-gray-300">Use StremThru to manage debrid services.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- StremThru URL Input -->
|
||||||
|
<div id="stremthru_url_div" class="hidden">
|
||||||
|
<label for="stremthru_url" class="block text-sm font-medium leading-6 text-white">StremThru URL</label>
|
||||||
|
<input type="text" name="stremthru_url" id="stremthru_url"
|
||||||
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="https://stremthru.13377001.xyz/">
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Enter the URL of your StremThru instance.</p>
|
||||||
|
</div>
|
||||||
|
<!-- StremThru Store Auth Input -->
|
||||||
|
<div id="stremthru_auth_div" class="hidden">
|
||||||
|
<label for="stremthru_store_auth" class="block text-sm font-medium leading-6 text-white">StremThru Store Authorization (Optional)</label>
|
||||||
|
<input type="text" name="stremthru_store_auth" id="stremthru_store_auth"
|
||||||
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
placeholder="Optional global auth key">
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Global key if required by your StremThru instance.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -161,7 +306,7 @@
|
||||||
<div class="flex h-6 items-center">
|
<div class="flex h-6 items-center">
|
||||||
<input id="cache" name="cache" type="checkbox" checked
|
<input id="cache" name="cache" type="checkbox" checked
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
onchange="updateProviderFields(true)">
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="cache" class="font-medium text-white">Enable Public Caching</label>
|
<label for="cache" class="font-medium text-white">Enable Public Caching</label>
|
||||||
|
|
@ -191,7 +336,7 @@
|
||||||
<input id="yggflix" name="yggflix" type="checkbox"
|
<input id="yggflix" name="yggflix" type="checkbox"
|
||||||
data-unique-account="{{ ygg_unique_account|lower }}"
|
data-unique-account="{{ ygg_unique_account|lower }}"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
onchange="updateProviderFields(true)">
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="yggflix" class="font-medium text-white">Enable YggTorrent API</label>
|
<label for="yggflix" class="font-medium text-white">Enable YggTorrent API</label>
|
||||||
|
|
@ -205,7 +350,7 @@
|
||||||
<input id="sharewood" name="sharewood" type="checkbox"
|
<input id="sharewood" name="sharewood" type="checkbox"
|
||||||
data-unique-account="{{ sharewood_unique_account|lower }}"
|
data-unique-account="{{ sharewood_unique_account|lower }}"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
onchange="updateProviderFields(true)">
|
onchange="updateProviderFields()">
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<label for="sharewood" class="font-medium text-white">Enable Sharewood API</label>
|
<label for="sharewood" class="font-medium text-white">Enable Sharewood API</label>
|
||||||
|
|
@ -217,6 +362,32 @@
|
||||||
you may prefere to disable it to save time on search</span></p>
|
you may prefere to disable it to save time on search</span></p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -326,20 +497,11 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not tb_unique_account %}
|
|
||||||
<div id="tb_debrid-fields" style="display: none;">
|
<div id="tb_debrid-fields" style="display: none;">
|
||||||
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
|
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<h2 class="text-white font-semibold leading-7">Torbox Configuration</h2>
|
<h2 class="text-white font-semibold leading-7">Torbox Configuration</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="tb_token_info" class="block text-sm font-medium leading-6 text-white">Torbox
|
|
||||||
Token</label>
|
|
||||||
<input type="text" name="tb_token_info" id="tb_token_info"
|
|
||||||
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<div class="relative flex gap-x-3">
|
<div class="relative flex gap-x-3">
|
||||||
<div class="flex h-6 items-center">
|
<div class="flex h-6 items-center">
|
||||||
|
|
@ -367,7 +529,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not pm_unique_account %}
|
{% if not pm_unique_account %}
|
||||||
<div id="pm_debrid-fields" style="display: none;">
|
<div id="pm_debrid-fields" style="display: none;">
|
||||||
|
|
@ -495,19 +656,38 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not ygg_unique_account %}
|
|
||||||
<div id="ygg-fields" style="display: none;">
|
{% if not c411_unique_account %}
|
||||||
|
<div id="c411-fields" style="display: none;">
|
||||||
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
|
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<h2 class="text-white font-semibold leading-7">YggTorrent Login</h2>
|
<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 YggTorrent account
|
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Enter your C411 API key here.</p>
|
||||||
information here.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||||
<div class="sm:col-span-4">
|
<div class="sm:col-span-4">
|
||||||
<label for="yggPasskey"
|
<label for="c411ApiKey"
|
||||||
class="block text-sm font-medium leading-6 text-white">YggTorrent PassKey</label>
|
class="block text-sm font-medium leading-6 text-white">API Key</label>
|
||||||
<input type="text" name="yggPasskey" id="yggPasskey"
|
<input type="text" name="c411ApiKey" id="c411ApiKey"
|
||||||
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not torr9_unique_account %}
|
||||||
|
<div id="torr9-fields" style="display: none;">
|
||||||
|
<div class="border-b border-gray-900/10 dark:border-white/50 pb-12">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<h2 class="text-white font-semibold leading-7">Torr9 Configuration</h2>
|
||||||
|
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-400">Enter your Torr9 API key here.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="torr9ApiKey"
|
||||||
|
class="block text-sm font-medium leading-6 text-white">API Key</label>
|
||||||
|
<input type="text" name="torr9ApiKey" id="torr9ApiKey"
|
||||||
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
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>
|
||||||
|
|
@ -526,7 +706,7 @@
|
||||||
<legend class="text-sm font-semibold leading-6 text-white">Sorting Options</legend>
|
<legend class="text-sm font-semibold leading-6 text-white">Sorting Options</legend>
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<input id="quality" name="sorting" type="radio"
|
<input id="quality" name="sorting" type="radio" checked
|
||||||
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
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
|
<label for="quality" class="block text-sm font-medium leading-6 text-white">Get the
|
||||||
best
|
best
|
||||||
|
|
@ -547,7 +727,7 @@
|
||||||
size ascending</label>
|
size ascending</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<input id="qualitythensize" name="sorting" type="radio" checked
|
<input id="qualitythensize" name="sorting" type="radio"
|
||||||
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
||||||
<label for="qualitythensize"
|
<label for="qualitythensize"
|
||||||
class="block text-sm font-medium leading-6 text-white">Filter
|
class="block text-sm font-medium leading-6 text-white">Filter
|
||||||
|
|
@ -600,7 +780,7 @@
|
||||||
want to exclude</p>
|
want to exclude</p>
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<input id="2160p" name="2160p" type="checkbox" checked
|
<input id="2160p" name="2160p" type="checkbox"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
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
|
<label for="2160p" class="block text-sm font-medium leading-6 text-white">Exclude
|
||||||
2160p
|
2160p
|
||||||
|
|
@ -621,7 +801,7 @@
|
||||||
resolution</label>
|
resolution</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<input id="480p" name="480p" type="checkbox"
|
<input id="480p" name="480p" type="checkbox" checked
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600">
|
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
|
<label for="480p" class="block text-sm font-medium leading-6 text-white">Exclude
|
||||||
480p
|
480p
|
||||||
|
|
@ -701,6 +881,12 @@
|
||||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
|
||||||
<span class="ml-2 text-white">MULTi</span>
|
<span class="ml-2 text-white">MULTi</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<input id="vfq" type="checkbox" name="language" value="vfq"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
|
||||||
|
<span class="ml-2 text-white">Français québécois (VFQ)</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400">(Juste pour les québécois - VFQ en priorité)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -726,7 +912,7 @@
|
||||||
<label for="maxSize" class="block text-sm font-medium leading-6 text-white">Maximum file
|
<label for="maxSize" class="block text-sm font-medium leading-6 text-white">Maximum file
|
||||||
size
|
size
|
||||||
(GB)</label>
|
(GB)</label>
|
||||||
<input type="number" name="maxSize" id="maxSize" min="0" step="0.1" value="18"
|
<input type="number" name="maxSize" id="maxSize" min="0" step="0.1" value="200"
|
||||||
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
class="mt-2 block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -754,14 +940,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex items-center justify-center gap-x-6">
|
<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>
|
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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
BIN
stream_fusion/static/videos/access_denied.mp4
Normal file
BIN
stream_fusion/static/videos/access_denied.mp4
Normal file
Binary file not shown.
BIN
stream_fusion/static/videos/error.mp4
Normal file
BIN
stream_fusion/static/videos/error.mp4
Normal file
Binary file not shown.
BIN
stream_fusion/static/videos/expired_api_key.mp4
Normal file
BIN
stream_fusion/static/videos/expired_api_key.mp4
Normal file
Binary file not shown.
BIN
stream_fusion/static/videos/not_premium.mp4
Normal file
BIN
stream_fusion/static/videos/not_premium.mp4
Normal file
Binary file not shown.
BIN
stream_fusion/static/videos/not_ready.mp4
Normal file
BIN
stream_fusion/static/videos/not_ready.mp4
Normal file
Binary file not shown.
BIN
stream_fusion/static/videos/slots_full.mp4
Normal file
BIN
stream_fusion/static/videos/slots_full.mp4
Normal file
Binary file not shown.
BIN
stream_fusion/static/videos/torbox_rate_limit.mp4
Normal file
BIN
stream_fusion/static/videos/torbox_rate_limit.mp4
Normal file
Binary file not shown.
BIN
stream_fusion/static/videos/two_factor_auth.mp4
Normal file
BIN
stream_fusion/static/videos/two_factor_auth.mp4
Normal file
Binary file not shown.
1
stream_fusion/utils/__init__.py
Normal file
1
stream_fusion/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Package initialization for utils
|
||||||
0
stream_fusion/utils/c411/__init__.py
Normal file
0
stream_fusion/utils/c411/__init__.py
Normal file
153
stream_fusion/utils/c411/c411_api.py
Normal file
153
stream_fusion/utils/c411/c411_api.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import re
|
||||||
|
import aiohttp
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class C411RawResult:
|
||||||
|
def __init__(self):
|
||||||
|
self.raw_title: Optional[str] = None
|
||||||
|
self.size: Optional[str] = None
|
||||||
|
self.link: Optional[str] = None
|
||||||
|
self.indexer: str = "C411 - API"
|
||||||
|
self.seeders: int = 0
|
||||||
|
self.magnet: Optional[str] = None
|
||||||
|
self.info_hash: Optional[str] = None
|
||||||
|
self.privacy: str = "public"
|
||||||
|
|
||||||
|
|
||||||
|
class C411API:
|
||||||
|
TORZNAB_NS = {"torznab": "http://torznab.com/schemas/2015/feed"}
|
||||||
|
|
||||||
|
def __init__(self, session: Optional[aiohttp.ClientSession] = None, api_key: Optional[str] = None):
|
||||||
|
self.base_url = settings.c411_url.rstrip("/") + "/api"
|
||||||
|
self.api_key = api_key if api_key is not None else settings.c411_api_key
|
||||||
|
self._external_session = session is not None
|
||||||
|
self._session = session
|
||||||
|
self._timeout = aiohttp.ClientTimeout(sock_read=2)
|
||||||
|
|
||||||
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
||||||
|
self._external_session = False
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def _request_xml(self, params: dict) -> Optional[str]:
|
||||||
|
if not self.api_key:
|
||||||
|
logger.warning("C411: API key not configured (C411_API_KEY), skipping request")
|
||||||
|
return None
|
||||||
|
# Torznab standard : apikey en query param (pas Bearer header)
|
||||||
|
params["apikey"] = self.api_key
|
||||||
|
session = await self._get_session()
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
self.base_url, params=params, allow_redirects=True,
|
||||||
|
timeout=aiohttp.ClientTimeout(sock_read=2, total=5)
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
return await response.text()
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"C411: HTTP error: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C411: Unexpected error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_xml(self, xml_content: str) -> List[C411RawResult]:
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_content)
|
||||||
|
except ET.ParseError as e:
|
||||||
|
logger.error(f"C411: XML parse error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
for item in root.findall(".//item"):
|
||||||
|
try:
|
||||||
|
result = C411RawResult()
|
||||||
|
|
||||||
|
seeders_el = item.find(
|
||||||
|
'.//torznab:attr[@name="seeders"]', self.TORZNAB_NS
|
||||||
|
)
|
||||||
|
result.seeders = int(seeders_el.attrib["value"]) if seeders_el is not None else 0
|
||||||
|
|
||||||
|
title_el = item.find("title")
|
||||||
|
if title_el is None or not title_el.text:
|
||||||
|
continue
|
||||||
|
result.raw_title = title_el.text
|
||||||
|
|
||||||
|
size_el = item.find("size")
|
||||||
|
result.size = size_el.text if size_el is not None else "0"
|
||||||
|
|
||||||
|
link_el = item.find("link")
|
||||||
|
result.link = link_el.text if link_el is not None else None
|
||||||
|
|
||||||
|
magnet_el = item.find(
|
||||||
|
'.//torznab:attr[@name="magneturl"]', self.TORZNAB_NS
|
||||||
|
)
|
||||||
|
result.magnet = magnet_el.attrib["value"] if magnet_el is not None else None
|
||||||
|
|
||||||
|
hash_el = item.find(
|
||||||
|
'.//torznab:attr[@name="infohash"]', self.TORZNAB_NS
|
||||||
|
)
|
||||||
|
result.info_hash = hash_el.attrib["value"] if hash_el is not None else None
|
||||||
|
|
||||||
|
type_el = item.find("type")
|
||||||
|
result.privacy = type_el.text if type_el is not None else "public"
|
||||||
|
|
||||||
|
# Extraire hash depuis magnet si manquant
|
||||||
|
if not result.info_hash and result.magnet:
|
||||||
|
m = re.search(r"btih:([a-fA-F0-9]{40})", result.magnet, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
result.info_hash = m.group(1).lower()
|
||||||
|
|
||||||
|
if result.info_hash and len(result.info_hash) == 40:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"C411: Error parsing item: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def search_movie(
|
||||||
|
self,
|
||||||
|
tmdb_id: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
) -> List[C411RawResult]:
|
||||||
|
params = {"t": "movie", "cat": "2000"}
|
||||||
|
if tmdb_id:
|
||||||
|
params["tmdbid"] = tmdb_id
|
||||||
|
elif title:
|
||||||
|
params["q"] = title
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
xml = await self._request_xml(params)
|
||||||
|
results = self._parse_xml(xml) if xml else []
|
||||||
|
logger.info(f"C411: search_movie tmdb={tmdb_id} → {len(results)} results")
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def search_series(
|
||||||
|
self,
|
||||||
|
tmdb_id: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
season: Optional[int] = None,
|
||||||
|
episode: Optional[int] = None,
|
||||||
|
) -> List[C411RawResult]:
|
||||||
|
params = {"t": "tvsearch", "cat": "5000"}
|
||||||
|
if tmdb_id:
|
||||||
|
params["tmdbid"] = tmdb_id
|
||||||
|
elif title:
|
||||||
|
params["q"] = title
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
if season is not None:
|
||||||
|
params["season"] = season
|
||||||
|
if episode is not None:
|
||||||
|
params["ep"] = episode
|
||||||
|
xml = await self._request_xml(params)
|
||||||
|
results = self._parse_xml(xml) if xml else []
|
||||||
|
logger.info(f"C411: search_series tmdb={tmdb_id} s={season} e={episode} → {len(results)} results")
|
||||||
|
return results
|
||||||
66
stream_fusion/utils/c411/c411_result.py
Normal file
66
stream_fusion/utils/c411/c411_result.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
|
||||||
|
from RTN import parse
|
||||||
|
|
||||||
|
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
||||||
|
from stream_fusion.utils.detection import detect_languages
|
||||||
|
from urllib.parse import quote
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class C411Result:
|
||||||
|
def __init__(self):
|
||||||
|
self.raw_title = None
|
||||||
|
self.size = None
|
||||||
|
self.link = None
|
||||||
|
self.indexer = "C411 - API"
|
||||||
|
self.seeders = 0
|
||||||
|
self.magnet = None
|
||||||
|
self.info_hash = None
|
||||||
|
self.privacy = "public"
|
||||||
|
self.languages = None
|
||||||
|
self.type = None
|
||||||
|
self.parsed_data = None
|
||||||
|
self.torrent_download = None
|
||||||
|
|
||||||
|
def convert_to_torrent_item(self):
|
||||||
|
parsed_data = self.parsed_data or parse(self.raw_title)
|
||||||
|
return TorrentItem(
|
||||||
|
raw_title=self.raw_title,
|
||||||
|
size=self.size,
|
||||||
|
magnet=self.magnet,
|
||||||
|
info_hash=self.info_hash.lower() if self.info_hash else None,
|
||||||
|
link=self.link or self.magnet,
|
||||||
|
seeders=self.seeders,
|
||||||
|
languages=self.languages,
|
||||||
|
indexer=self.indexer,
|
||||||
|
privacy=self.privacy,
|
||||||
|
type=self.type,
|
||||||
|
parsed_data=parsed_data,
|
||||||
|
torrent_download=self.torrent_download,
|
||||||
|
tmdb_id=self.tmdb_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def from_api_item(self, api_item, media):
|
||||||
|
self.info_hash = api_item.info_hash.lower() if api_item.info_hash else None
|
||||||
|
if not self.info_hash or len(self.info_hash) != 40:
|
||||||
|
raise ValueError(f"Invalid info_hash: {self.info_hash}")
|
||||||
|
|
||||||
|
parsed = parse(api_item.raw_title)
|
||||||
|
self.raw_title = parsed.raw_title
|
||||||
|
self.parsed_data = parsed
|
||||||
|
self.size = api_item.size or "0"
|
||||||
|
c411_tracker = settings.c411_passkey or ""
|
||||||
|
if c411_tracker:
|
||||||
|
self.magnet = f"magnet:?xt=urn:btih:{self.info_hash}&dn={self.raw_title}&tr={quote(c411_tracker, safe='')}"
|
||||||
|
else:
|
||||||
|
self.magnet = f"magnet:?xt=urn:btih:{self.info_hash}&dn={self.raw_title}"
|
||||||
|
self.link = self.magnet
|
||||||
|
self.seeders = api_item.seeders or 0
|
||||||
|
self.privacy = api_item.privacy or "public"
|
||||||
|
self.languages = detect_languages(self.raw_title, default_language="fr")
|
||||||
|
self.type = media.type
|
||||||
|
self.tmdb_id = getattr(media, 'tmdb_id', None)
|
||||||
|
base = settings.c411_url.rstrip("/")
|
||||||
|
self.torrent_download = f"{base}/api?t=get&id={self.info_hash}&apikey={settings.c411_api_key}"
|
||||||
|
return self
|
||||||
65
stream_fusion/utils/c411/c411_service.py
Normal file
65
stream_fusion/utils/c411/c411_service.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.settings import settings
|
||||||
|
from stream_fusion.utils.c411.c411_api import C411API
|
||||||
|
from stream_fusion.utils.c411.c411_result import C411Result
|
||||||
|
from stream_fusion.utils.models.movie import Movie
|
||||||
|
from stream_fusion.utils.models.series import Series
|
||||||
|
|
||||||
|
|
||||||
|
class C411Service:
|
||||||
|
def __init__(self, config: dict, session: Optional[aiohttp.ClientSession] = None):
|
||||||
|
self.config = config
|
||||||
|
if settings.c411_unique_account and settings.c411_api_key:
|
||||||
|
api_key = settings.c411_api_key
|
||||||
|
else:
|
||||||
|
api_key = config.get("c411ApiKey") or settings.c411_api_key
|
||||||
|
self.api = C411API(session=session, api_key=api_key)
|
||||||
|
self.has_tmdb = config.get("metadataProvider") == "tmdb"
|
||||||
|
|
||||||
|
async def search(self, media: Union[Movie, Series]) -> List[C411Result]:
|
||||||
|
try:
|
||||||
|
if isinstance(media, Movie):
|
||||||
|
return await self._search_movie(media)
|
||||||
|
elif isinstance(media, Series):
|
||||||
|
return await self._search_series(media)
|
||||||
|
else:
|
||||||
|
raise TypeError("Only Movie and Series are supported")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C411: Search error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _search_movie(self, media: Movie) -> List[C411Result]:
|
||||||
|
logger.info(f"C411: Searching movie: {media.titles[0]}")
|
||||||
|
tmdb_id = str(media.tmdb_id) if self.has_tmdb and media.tmdb_id else None
|
||||||
|
if not tmdb_id:
|
||||||
|
logger.debug(f"C411: No TMDB ID available, skipping search for '{media.titles[0]}'")
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw = await self.api.search_movie(tmdb_id=tmdb_id)
|
||||||
|
logger.info(f"C411: {len(raw)} raw results for movie '{media.titles[0]}'")
|
||||||
|
return self._build_results(raw, media)
|
||||||
|
|
||||||
|
async def _search_series(self, media: Series) -> List[C411Result]:
|
||||||
|
logger.info(f"C411: Searching series (global): {media.titles[0]}")
|
||||||
|
tmdb_id = str(media.tmdb_id) if self.has_tmdb and media.tmdb_id else None
|
||||||
|
if not tmdb_id:
|
||||||
|
logger.debug(f"C411: No TMDB ID available, skipping search for '{media.titles[0]}'")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Recherche globale (sans saison/épisode) pour tout stocker en Postgres
|
||||||
|
raw = await self.api.search_series(tmdb_id=tmdb_id)
|
||||||
|
logger.info(f"C411: {len(raw)} raw results for '{media.titles[0]}' (global)")
|
||||||
|
return self._build_results(raw, media)
|
||||||
|
|
||||||
|
def _build_results(self, raw_results, media) -> List[C411Result]:
|
||||||
|
results = []
|
||||||
|
for item in raw_results:
|
||||||
|
try:
|
||||||
|
result = C411Result().from_api_item(item, media)
|
||||||
|
results.append(result)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.debug(f"C411: Skipping item — {e}")
|
||||||
|
return results
|
||||||
16
stream_fusion/utils/cache/cache.py
vendored
16
stream_fusion/utils/cache/cache.py
vendored
|
|
@ -11,6 +11,9 @@ from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
def search_public(media):
|
def search_public(media):
|
||||||
|
if not settings.public_cache_url:
|
||||||
|
return None
|
||||||
|
|
||||||
logger.info("Searching for public cached " + media.type + " results")
|
logger.info("Searching for public cached " + media.type + " results")
|
||||||
url = settings.public_cache_url + "getResult/" + media.type + "/"
|
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?
|
# Without that, the cache doesn't return results. Maybe make multiple requests? One for each language, just like jackett?
|
||||||
|
|
@ -19,10 +22,21 @@ def search_public(media):
|
||||||
cache_search["language"] = cache_search["languages"][0]
|
cache_search["language"] = cache_search["languages"][0]
|
||||||
# Wtf, why do we need to use __dict__ here? And also, why is it stuck when we use media directly?
|
# Wtf, why do we need to use __dict__ here? And also, why is it stuck when we use media directly?
|
||||||
response = requests.get(url, json=cache_search)
|
response = requests.get(url, json=cache_search)
|
||||||
return response.json()
|
try:
|
||||||
|
response.raise_for_status() # Lève une exception pour les codes d'état HTTP d'erreur (4xx ou 5xx)
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Search: Erreur lors de la récupération des résultats du cache public: {e}")
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Search: Erreur de décodage JSON pour le cache public: {e}. Contenu de la réponse: {response.text[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cache_public(torrents: List[TorrentItem], media):
|
def cache_public(torrents: List[TorrentItem], media):
|
||||||
|
if not settings.public_cache_url:
|
||||||
|
return
|
||||||
|
|
||||||
if os.getenv("NODE_ENV") == "development":
|
if os.getenv("NODE_ENV") == "development":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
17
stream_fusion/utils/cache/local_redis.py
vendored
17
stream_fusion/utils/cache/local_redis.py
vendored
|
|
@ -25,7 +25,9 @@ class RedisCache(CacheBase):
|
||||||
try:
|
try:
|
||||||
self._redis_client = Redis.from_url(
|
self._redis_client = Redis.from_url(
|
||||||
self.redis_url,
|
self.redis_url,
|
||||||
max_connections=10
|
max_connections=100,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
socket_timeout=10
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"RedisCache: Failed to create Redis client: {e}")
|
self.logger.error(f"RedisCache: Failed to create Redis client: {e}")
|
||||||
|
|
@ -216,10 +218,19 @@ class RedisCache(CacheBase):
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
if self._redis_client:
|
if self._redis_client:
|
||||||
await self._redis_client.close()
|
try:
|
||||||
|
# Set a short timeout for closing to avoid blocking
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._redis_client.close(),
|
||||||
|
timeout=2.0
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.logger.warning("RedisCache: Timeout while closing Redis connection, ignoring")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"RedisCache: Error while closing Redis connection: {e}")
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
await self.close()
|
await self.close()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# alldebrid.py
|
# alldebrid.py - AllDebrid v4.1 API (using magnet/status for files)
|
||||||
import uuid
|
import uuid
|
||||||
|
import aiohttp
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -10,9 +11,29 @@ from stream_fusion.logging_config import logger
|
||||||
from stream_fusion.settings import settings
|
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):
|
class AllDebrid(BaseDebrid):
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||||
super().__init__(config)
|
super().__init__(config, session)
|
||||||
self.base_url = f"{settings.ad_base_url}/{settings.ad_api_version}/"
|
self.base_url = f"{settings.ad_base_url}/{settings.ad_api_version}/"
|
||||||
self.agent = settings.ad_user_app
|
self.agent = settings.ad_user_app
|
||||||
|
|
||||||
|
|
@ -30,98 +51,178 @@ class AllDebrid(BaseDebrid):
|
||||||
else:
|
else:
|
||||||
return {"Authorization": f"Bearer {self.config.get('ADToken')}"}
|
return {"Authorization": f"Bearer {self.config.get('ADToken')}"}
|
||||||
|
|
||||||
def add_magnet(self, magnet, ip=None):
|
async def add_magnet(self, magnet, ip=None):
|
||||||
url = f"{self.base_url}magnet/upload?agent={self.agent}"
|
url = f"{self.base_url}magnet/upload?agent={self.agent}"
|
||||||
data = {"magnets[]": magnet}
|
data = {"magnets[]": magnet}
|
||||||
return self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
return await self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||||
|
|
||||||
def add_torrent(self, torrent_file, ip=None):
|
async def add_torrent(self, torrent_file, ip=None):
|
||||||
url = f"{self.base_url}magnet/upload/file?agent={self.agent}"
|
url = f"{self.base_url}magnet/upload/file?agent={self.agent}"
|
||||||
files = {"files[]": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')}
|
files = {"files[]": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')}
|
||||||
return self.json_response(url, method='post', headers=self.get_headers(), files=files)
|
return await self.json_response(url, method='post', headers=self.get_headers(), files=files)
|
||||||
|
|
||||||
def check_magnet_status(self, id, ip=None):
|
async def get_magnet_files(self, id, ip=None):
|
||||||
url = f"{self.base_url}magnet/status?agent={self.agent}&id={id}"
|
"""Get files from magnet using v4 API"""
|
||||||
return self.json_response(url, method='get', headers=self.get_headers())
|
url = f"{settings.ad_base_url}/v4/magnet/files"
|
||||||
|
data = {"id[]": id, "agent": self.agent}
|
||||||
|
return await self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||||
|
|
||||||
def unrestrict_link(self, link, ip=None):
|
async def unrestrict_link(self, link, ip=None):
|
||||||
url = f"{self.base_url}link/unlock?agent={self.agent}&link={link}"
|
url = f"{self.base_url}link/unlock?agent={self.agent}&link={link}"
|
||||||
return self.json_response(url, method='get', headers=self.get_headers())
|
return await self.json_response(url, method='get', headers=self.get_headers())
|
||||||
|
|
||||||
def get_stream_link(self, query, config, ip=None):
|
async def get_stream_link(self, query, config=None, ip=None):
|
||||||
magnet = query['magnet']
|
magnet = query['magnet']
|
||||||
stream_type = query['type']
|
stream_type = query['type']
|
||||||
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
|
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
|
||||||
|
torrent_file_content = query.get("torrent_file_content", None)
|
||||||
|
|
||||||
torrent_id = self.add_magnet_or_torrent(magnet, torrent_download, ip)
|
# Add magnet or torrent to AllDebrid
|
||||||
|
torrent_id = await self.add_magnet_or_torrent(magnet, torrent_download, torrent_file_content, ip)
|
||||||
|
torrent_id = str(torrent_id) if torrent_id else ""
|
||||||
logger.info(f"AllDebrid: Torrent ID: {torrent_id}")
|
logger.info(f"AllDebrid: Torrent ID: {torrent_id}")
|
||||||
|
|
||||||
if not self.wait_for_ready_status(
|
if not torrent_id or torrent_id.startswith("Error"):
|
||||||
lambda: self.check_magnet_status(torrent_id, ip)["data"]["magnets"]["status"] == "Ready"):
|
logger.error(f"AllDebrid: Failed to add torrent: {torrent_id}")
|
||||||
logger.error("AllDebrid: Torrent not ready, caching in progress.")
|
|
||||||
return settings.no_cache_video_url
|
return settings.no_cache_video_url
|
||||||
logger.info("AllDebrid: Torrent is ready.")
|
|
||||||
|
|
||||||
logger.info(f"AllDebrid: Retrieving data for torrent ID: {torrent_id}")
|
# Get files from magnet using v4 API
|
||||||
data = self.check_magnet_status(torrent_id, ip)["data"]
|
logger.info(f"AllDebrid: Retrieving files for torrent ID: {torrent_id}")
|
||||||
logger.info(f"AllDebrid: Data retrieved for torrent ID")
|
try:
|
||||||
|
files_response = await self.get_magnet_files(torrent_id, ip)
|
||||||
|
logger.debug(f"AllDebrid: Files response: {files_response}")
|
||||||
|
|
||||||
|
if not files_response:
|
||||||
|
logger.error("AllDebrid: Null response from get_magnet_files")
|
||||||
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
|
if files_response.get("status") != "success":
|
||||||
|
logger.warning(f"AllDebrid: get_magnet_files returned error: {files_response.get('error')}")
|
||||||
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
|
if "data" not in files_response:
|
||||||
|
logger.error("AllDebrid: No data in files response")
|
||||||
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
|
magnets = files_response["data"].get("magnets", [])
|
||||||
|
if not magnets or len(magnets) == 0:
|
||||||
|
logger.error("AllDebrid: No magnet data in files response")
|
||||||
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
|
magnet_data = magnets[0]
|
||||||
|
files = magnet_data.get("files", [])
|
||||||
|
|
||||||
|
# Flatten nested file structure
|
||||||
|
files = flatten_files(files)
|
||||||
|
logger.info(f"AllDebrid: Retrieved {len(files)} files (after flattening)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AllDebrid: Error getting magnet files: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.debug(f"AllDebrid: Traceback: {traceback.format_exc()}")
|
||||||
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
link = settings.no_cache_video_url
|
link = settings.no_cache_video_url
|
||||||
|
|
||||||
if stream_type == "movie":
|
if stream_type == "movie":
|
||||||
logger.info("AllDebrid: Getting link for movie")
|
logger.info("AllDebrid: Finding largest file for movie")
|
||||||
link = max(data["magnets"]['links'], key=lambda x: x['size'])['link']
|
try:
|
||||||
|
largest_file = max(files, key=lambda x: x.get("s", 0)) if files else None
|
||||||
|
|
||||||
|
if largest_file and "l" in largest_file:
|
||||||
|
link = largest_file["l"]
|
||||||
|
logger.info(f"AllDebrid: Found movie link")
|
||||||
|
else:
|
||||||
|
logger.error("AllDebrid: No valid link found for movie")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AllDebrid: Error processing movie: {str(e)}")
|
||||||
|
|
||||||
elif stream_type == "series":
|
elif stream_type == "series":
|
||||||
numeric_season = int(query['season'].replace("S", ""))
|
numeric_season = int(query['season'].replace("S", ""))
|
||||||
numeric_episode = int(query['episode'].replace("E", ""))
|
numeric_episode = int(query['episode'].replace("E", ""))
|
||||||
logger.info(f"AllDebrid: Getting link for series S{numeric_season:02d}E{numeric_episode:02d}")
|
logger.info(f"AllDebrid: Finding S{numeric_season:02d}E{numeric_episode:02d}")
|
||||||
|
|
||||||
matching_files = []
|
try:
|
||||||
for file in data["magnets"]["links"]:
|
matching_files = []
|
||||||
if season_episode_in_filename(file["filename"], numeric_season, numeric_episode):
|
for file_info in files:
|
||||||
matching_files.append(file)
|
if isinstance(file_info, dict):
|
||||||
|
filename = file_info.get("n", "")
|
||||||
|
|
||||||
if len(matching_files) == 0:
|
if season_episode_in_filename(filename, numeric_season, numeric_episode):
|
||||||
logger.error(f"AllDebrid: No matching files for S{numeric_season:02d}E{numeric_episode:02d} in torrent.")
|
logger.debug(f"AllDebrid: ✓ Match: {filename}")
|
||||||
raise HTTPException(status_code=404, detail=f"No matching files for S{numeric_season:02d}E{numeric_episode:02d} in torrent.")
|
matching_files.append(file_info)
|
||||||
|
else:
|
||||||
|
import re
|
||||||
|
episode_patterns = [
|
||||||
|
rf"[Ss]{numeric_season:02d}[Ee]{numeric_episode:02d}",
|
||||||
|
rf"[Ss]{numeric_season}[Ee]{numeric_episode:02d}",
|
||||||
|
rf"{numeric_season:02d}x{numeric_episode:02d}",
|
||||||
|
rf"{numeric_season}x{numeric_episode:02d}",
|
||||||
|
rf"[Ss]eason.{numeric_season:02d}.*[Ee]{numeric_episode:02d}",
|
||||||
|
rf"[Ss]eason.{numeric_season}.*[Ee]{numeric_episode:02d}",
|
||||||
|
rf"[Ss]{numeric_season:02d}.*[Ee]pisode.{numeric_episode:02d}",
|
||||||
|
rf"[Ss]{numeric_season}.*[Ee]pisode.{numeric_episode:02d}",
|
||||||
|
rf"\b{numeric_episode:03d}\b",
|
||||||
|
rf"[Ee]p\.?\s*{numeric_episode:03d}\b",
|
||||||
|
rf"[Ee]pisode\s*{numeric_episode:03d}\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in episode_patterns:
|
||||||
|
if re.search(pattern, filename, re.IGNORECASE):
|
||||||
|
logger.debug(f"AllDebrid: ✓ Match with pattern: {filename}")
|
||||||
|
matching_files.append(file_info)
|
||||||
|
break
|
||||||
|
|
||||||
|
if matching_files:
|
||||||
|
target_file = max(matching_files, key=lambda x: x.get("s", 0))
|
||||||
|
if "l" in target_file:
|
||||||
|
link = target_file["l"]
|
||||||
|
logger.info(f"AllDebrid: Found episode link")
|
||||||
|
else:
|
||||||
|
logger.error("AllDebrid: Matching file has no link")
|
||||||
|
else:
|
||||||
|
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)}")
|
||||||
|
|
||||||
link = max(matching_files, key=lambda x: x["size"])["link"]
|
|
||||||
else:
|
else:
|
||||||
logger.error("AllDebrid: Unsupported stream type.")
|
logger.error("AllDebrid: Unsupported stream type.")
|
||||||
raise HTTPException(status_code=500, detail="Unsupported stream type.")
|
raise HTTPException(status_code=500, detail="Unsupported stream type.")
|
||||||
|
|
||||||
if link == settings.no_cache_video_url:
|
if link == settings.no_cache_video_url:
|
||||||
logger.info("AllDebrid: Video not cached, returning NO_CACHE_VIDEO_URL")
|
logger.info("AllDebrid: No link found, returning no-cache URL")
|
||||||
return link
|
return link
|
||||||
|
|
||||||
logger.info(f"AllDebrid: Retrieved link: {link}")
|
logger.info(f"AllDebrid: Retrieved link successfully")
|
||||||
|
|
||||||
unlocked_link_data = self.unrestrict_link(link, ip)
|
# Try to unrestrict the link
|
||||||
|
try:
|
||||||
|
unlocked_response = await self.unrestrict_link(link, ip)
|
||||||
|
if unlocked_response and unlocked_response.get("status") == "success" and "data" in unlocked_response:
|
||||||
|
final_link = unlocked_response["data"].get("link", link)
|
||||||
|
logger.info(f"AllDebrid: Link unrestricted")
|
||||||
|
return final_link
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"AllDebrid: Could not unrestrict link: {str(e)}")
|
||||||
|
|
||||||
if not unlocked_link_data:
|
return link
|
||||||
logger.error("AllDebrid: Failed to unlock link.")
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to unlock link in AllDebrid.")
|
|
||||||
|
|
||||||
logger.info(f"AllDebrid: Unrestricted link: {unlocked_link_data['data']['link']}")
|
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||||
|
|
||||||
return unlocked_link_data["data"]["link"]
|
|
||||||
|
|
||||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
|
||||||
if len(hashes_or_magnets) == 0:
|
if len(hashes_or_magnets) == 0:
|
||||||
logger.info("AllDebrid: No hashes to be sent.")
|
logger.info("AllDebrid: No hashes to check")
|
||||||
return {"status": "success", "data": {"magnets": []}}
|
return {"status": "success", "data": {"magnets": []}}
|
||||||
|
|
||||||
result_magnets = []
|
result_magnets = []
|
||||||
for hash_or_magnet in hashes_or_magnets:
|
for hash_or_magnet in hashes_or_magnets:
|
||||||
try:
|
try:
|
||||||
# Marquer tous les magnets comme disponibles immédiatement
|
|
||||||
result_magnets.append({
|
result_magnets.append({
|
||||||
"hash": hash_or_magnet,
|
"hash": hash_or_magnet,
|
||||||
"instant": True,
|
"instant": True,
|
||||||
"files": [] # Les fichiers seront remplis plus tard lors de l'appel à _update_availability_alldebrid
|
"files": []
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AllDebrid: Error processing hash {hash_or_magnet}: {str(e)}")
|
logger.error(f"AllDebrid: Error processing hash {hash_or_magnet}: {str(e)}")
|
||||||
# Même en cas d'erreur, on marque comme disponible
|
|
||||||
result_magnets.append({
|
result_magnets.append({
|
||||||
"hash": hash_or_magnet,
|
"hash": hash_or_magnet,
|
||||||
"instant": True,
|
"instant": True,
|
||||||
|
|
@ -130,30 +231,59 @@ class AllDebrid(BaseDebrid):
|
||||||
|
|
||||||
return {"status": "success", "data": {"magnets": result_magnets}}
|
return {"status": "success", "data": {"magnets": result_magnets}}
|
||||||
|
|
||||||
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
async def add_magnet_or_torrent(self, magnet, torrent_download=None, torrent_file_content=None, ip=None):
|
||||||
|
logger.debug(f"AllDebrid: Adding magnet or torrent")
|
||||||
torrent_id = ""
|
torrent_id = ""
|
||||||
if torrent_download is None:
|
|
||||||
logger.info(f"AllDebrid: Adding magnet")
|
|
||||||
magnet_response = self.add_magnet(magnet, ip)
|
|
||||||
logger.info(f"AllDebrid: Add magnet response received")
|
|
||||||
|
|
||||||
if not magnet_response or "status" not in magnet_response or magnet_response["status"] != "success":
|
# PRIORITE 1: Use cached .torrent file
|
||||||
return "Error: Failed to add magnet."
|
if torrent_file_content is not None:
|
||||||
|
logger.info(f"AllDebrid: Attempting to add cached .torrent file")
|
||||||
|
try:
|
||||||
|
upload_response = await self.add_torrent(torrent_file_content, ip)
|
||||||
|
if upload_response and upload_response.get("status") == "success":
|
||||||
|
files = upload_response.get("data", {}).get("files", [])
|
||||||
|
if files and len(files) > 0:
|
||||||
|
torrent_id = files[0].get("id")
|
||||||
|
if torrent_id:
|
||||||
|
logger.info(f"AllDebrid: Successfully added cached .torrent, ID: {torrent_id}")
|
||||||
|
return str(torrent_id)
|
||||||
|
logger.warning(f"AllDebrid: Failed to add cached .torrent")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"AllDebrid: Exception with cached .torrent: {str(e)}")
|
||||||
|
|
||||||
torrent_id = magnet_response["data"]["magnets"][0]["id"]
|
# PRIORITE 2: Download and add .torrent file
|
||||||
else:
|
if torrent_download is not None:
|
||||||
logger.info(f"AllDebrid: Downloading torrent file")
|
logger.info(f"AllDebrid: Downloading and adding .torrent file")
|
||||||
torrent_file = self.download_torrent_file(torrent_download)
|
try:
|
||||||
logger.info(f"AllDebrid: Torrent file downloaded")
|
torrent_file = await self.download_torrent_file(torrent_download)
|
||||||
|
upload_response = await self.add_torrent(torrent_file, ip)
|
||||||
|
|
||||||
logger.info(f"AllDebrid: Adding torrent file")
|
if upload_response and upload_response.get("status") == "success":
|
||||||
upload_response = self.add_torrent(torrent_file, ip)
|
files = upload_response.get("data", {}).get("files", [])
|
||||||
logger.info(f"AllDebrid: Add torrent file response received")
|
if files and len(files) > 0:
|
||||||
|
torrent_id = files[0].get("id")
|
||||||
|
if torrent_id:
|
||||||
|
logger.info(f"AllDebrid: Successfully added downloaded .torrent, ID: {torrent_id}")
|
||||||
|
return str(torrent_id)
|
||||||
|
logger.warning(f"AllDebrid: Failed to add downloaded .torrent, falling back to magnet")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"AllDebrid: Exception downloading .torrent: {str(e)}")
|
||||||
|
|
||||||
if not upload_response or "status" not in upload_response or upload_response["status"] != "success":
|
# PRIORITE 3: Fall back to magnet
|
||||||
return "Error: Failed to add torrent file in AllDebrid."
|
logger.info(f"AllDebrid: Adding magnet link")
|
||||||
|
magnet_response = await self.add_magnet(magnet, ip)
|
||||||
|
|
||||||
torrent_id = upload_response["data"]["files"][0]["id"]
|
if not magnet_response or magnet_response.get("status") != "success":
|
||||||
|
logger.error(f"AllDebrid: Failed to add magnet: {magnet_response}")
|
||||||
|
return "Error: Failed to add magnet."
|
||||||
|
|
||||||
logger.info(f"AllDebrid: New torrent ID: {torrent_id}")
|
try:
|
||||||
return torrent_id
|
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."
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
from collections import deque
|
from collections import deque
|
||||||
import json
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import requests
|
import aiohttp
|
||||||
|
from aiohttp_socks import ProxyConnector
|
||||||
|
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
class BaseDebrid:
|
class BaseDebrid:
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.__session = self._create_session()
|
self._external_session = session is not None
|
||||||
|
self._session = session
|
||||||
|
|
||||||
# Rate limiters
|
# Rate limiters
|
||||||
self.global_limit = 250
|
self.global_limit = 250
|
||||||
|
|
@ -23,17 +25,28 @@ class BaseDebrid:
|
||||||
self.global_requests = deque()
|
self.global_requests = deque()
|
||||||
self.torrent_requests = deque()
|
self.torrent_requests = deque()
|
||||||
|
|
||||||
def _create_session(self):
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
session = requests.Session()
|
"""Get or create an aiohttp session with optional proxy support."""
|
||||||
if settings.proxy_url:
|
if self._session is None or self._session.closed:
|
||||||
self.logger.info(f"BaseDebrid: Using proxy: {settings.proxy_url}")
|
connector = None
|
||||||
session.proxies = {
|
if settings.proxy_url:
|
||||||
"http": str(settings.proxy_url),
|
self.logger.debug(f"BaseDebrid: Using proxy: {settings.proxy_url}")
|
||||||
"https": str(settings.proxy_url),
|
connector = ProxyConnector.from_url(str(settings.proxy_url))
|
||||||
}
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _rate_limit(self, requests_queue, limit, period):
|
timeout = aiohttp.ClientTimeout(total=30)
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the session if we own it."""
|
||||||
|
if self._session and not self._external_session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def _rate_limit(self, requests_queue, limit, period):
|
||||||
|
"""Async rate limiter using asyncio.sleep."""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
while requests_queue and requests_queue[0] <= current_time - period:
|
while requests_queue and requests_queue[0] <= current_time - period:
|
||||||
|
|
@ -42,63 +55,83 @@ class BaseDebrid:
|
||||||
if len(requests_queue) >= limit:
|
if len(requests_queue) >= limit:
|
||||||
sleep_time = requests_queue[0] - (current_time - period)
|
sleep_time = requests_queue[0] - (current_time - period)
|
||||||
if sleep_time > 0:
|
if sleep_time > 0:
|
||||||
time.sleep(sleep_time)
|
await asyncio.sleep(sleep_time)
|
||||||
|
|
||||||
requests_queue.append(time.time())
|
requests_queue.append(time.time())
|
||||||
|
|
||||||
def _global_rate_limit(self):
|
async def _global_rate_limit(self):
|
||||||
self._rate_limit(self.global_requests, self.global_limit, self.global_period)
|
await self._rate_limit(self.global_requests, self.global_limit, self.global_period)
|
||||||
|
|
||||||
def _torrent_rate_limit(self):
|
async def _torrent_rate_limit(self):
|
||||||
self._rate_limit(self.torrent_requests, self.torrent_limit, self.torrent_period)
|
await self._rate_limit(self.torrent_requests, self.torrent_limit, self.torrent_period)
|
||||||
|
|
||||||
def json_response(self, url, method="get", data=None, headers=None, files=None):
|
async def json_response(self, url, method="get", data=None, headers=None, files=None, timeout=30, retry_on_429=True):
|
||||||
self._global_rate_limit()
|
"""Make an async HTTP request and return JSON response."""
|
||||||
|
await self._global_rate_limit()
|
||||||
if "torrents" in url:
|
if "torrents" in url:
|
||||||
self._torrent_rate_limit()
|
await self._torrent_rate_limit()
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
request_timeout = aiohttp.ClientTimeout(total=timeout)
|
||||||
max_attempts = 5
|
max_attempts = 5
|
||||||
|
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
|
# Prepare request kwargs
|
||||||
|
kwargs = {
|
||||||
|
"headers": headers,
|
||||||
|
"timeout": request_timeout
|
||||||
|
}
|
||||||
|
|
||||||
if method == "get":
|
if method == "get":
|
||||||
response = self.__session.get(url, headers=headers)
|
async with session.get(url, **kwargs) as response:
|
||||||
|
await self._log_and_raise(response)
|
||||||
|
return await self._parse_json_response(response, attempt, max_attempts)
|
||||||
|
|
||||||
elif method == "post":
|
elif method == "post":
|
||||||
response = self.__session.post(
|
if files:
|
||||||
url, data=data, headers=headers, files=files
|
# Handle file uploads with FormData
|
||||||
)
|
form_data = aiohttp.FormData()
|
||||||
|
if data:
|
||||||
|
for key, value in data.items():
|
||||||
|
form_data.add_field(key, str(value))
|
||||||
|
for key, file_tuple in files.items():
|
||||||
|
if isinstance(file_tuple, tuple):
|
||||||
|
filename, file_content, content_type = file_tuple
|
||||||
|
form_data.add_field(key, file_content, filename=filename, content_type=content_type)
|
||||||
|
else:
|
||||||
|
form_data.add_field(key, file_tuple)
|
||||||
|
async with session.post(url, data=form_data, **kwargs) as response:
|
||||||
|
await self._log_and_raise(response)
|
||||||
|
return await self._parse_json_response(response, attempt, max_attempts)
|
||||||
|
else:
|
||||||
|
async with session.post(url, data=data, **kwargs) as response:
|
||||||
|
await self._log_and_raise(response)
|
||||||
|
return await self._parse_json_response(response, attempt, max_attempts)
|
||||||
|
|
||||||
elif method == "put":
|
elif method == "put":
|
||||||
response = self.__session.put(url, data=data, headers=headers)
|
async with session.put(url, data=data, **kwargs) as response:
|
||||||
|
await self._log_and_raise(response)
|
||||||
|
return await self._parse_json_response(response, attempt, max_attempts)
|
||||||
|
|
||||||
elif method == "delete":
|
elif method == "delete":
|
||||||
response = self.__session.delete(url, headers=headers)
|
async with session.delete(url, **kwargs) as response:
|
||||||
|
await self._log_and_raise(response)
|
||||||
|
return await self._parse_json_response(response, attempt, max_attempts)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"BaseDebrid: Unsupported HTTP method: {method}")
|
raise ValueError(f"BaseDebrid: Unsupported HTTP method: {method}")
|
||||||
|
|
||||||
response.raise_for_status()
|
except aiohttp.ClientResponseError as e:
|
||||||
|
status_code = e.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:
|
if status_code == 429:
|
||||||
|
if not retry_on_429:
|
||||||
|
self.logger.warning("BaseDebrid: Rate limit exceeded. No retry configured, returning None immediately.")
|
||||||
|
return None
|
||||||
wait_time = 2**attempt + 1
|
wait_time = 2**attempt + 1
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"BaseDebrid: Rate limit exceeded. Attempt {attempt + 1}/{max_attempts}. Waiting for {wait_time} seconds."
|
f"BaseDebrid: Rate limit exceeded. Attempt {attempt + 1}/{max_attempts}. Waiting for {wait_time} seconds."
|
||||||
)
|
)
|
||||||
time.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
elif 400 <= status_code < 500:
|
elif 400 <= status_code < 500:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"BaseDebrid: Client error occurred: {e}. Status code: {status_code}"
|
f"BaseDebrid: Client error occurred: {e}. Status code: {status_code}"
|
||||||
|
|
@ -113,7 +146,7 @@ class BaseDebrid:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"BaseDebrid: Retrying in {wait_time} seconds..."
|
f"BaseDebrid: Retrying in {wait_time} seconds..."
|
||||||
)
|
)
|
||||||
time.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
|
|
@ -121,23 +154,26 @@ class BaseDebrid:
|
||||||
f"BaseDebrid: Unexpected HTTP error occurred: {e}. Status code: {status_code}"
|
f"BaseDebrid: Unexpected HTTP error occurred: {e}. Status code: {status_code}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except requests.exceptions.ConnectionError as e:
|
|
||||||
|
except aiohttp.ClientConnectorError as e:
|
||||||
self.logger.error(f"BaseDebrid: Connection error occurred: {e}")
|
self.logger.error(f"BaseDebrid: Connection error occurred: {e}")
|
||||||
if attempt < max_attempts - 1:
|
if attempt < max_attempts - 1:
|
||||||
wait_time = 2**attempt + 1
|
wait_time = 2**attempt + 1
|
||||||
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
|
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
|
||||||
time.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except requests.exceptions.Timeout as e:
|
|
||||||
self.logger.error(f"BaseDebrid: Request timed out: {e}")
|
except asyncio.TimeoutError:
|
||||||
|
self.logger.error(f"BaseDebrid: Request timed out")
|
||||||
if attempt < max_attempts - 1:
|
if attempt < max_attempts - 1:
|
||||||
wait_time = 2**attempt + 1
|
wait_time = 2**attempt + 1
|
||||||
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
|
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
|
||||||
time.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
self.logger.error(f"BaseDebrid: An unexpected error occurred: {e}")
|
self.logger.error(f"BaseDebrid: An unexpected error occurred: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -146,30 +182,61 @@ class BaseDebrid:
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
|
async def _log_and_raise(self, response):
|
||||||
|
"""Log response body and headers on error before raising."""
|
||||||
|
if response.status >= 400:
|
||||||
|
try:
|
||||||
|
body = await response.text()
|
||||||
|
service = self.__class__.__name__
|
||||||
|
url = str(response.url)
|
||||||
|
headers = dict(response.headers)
|
||||||
|
self.logger.warning(f"{service}: HTTP {response.status} on {url} - body: {body[:500]} - headers: {headers}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def _parse_json_response(self, response, attempt, max_attempts):
|
||||||
|
"""Parse JSON from response with error handling."""
|
||||||
|
try:
|
||||||
|
return await response.json()
|
||||||
|
except Exception as json_err:
|
||||||
|
text = await response.text()
|
||||||
|
self.logger.error(f"BaseDebrid: Invalid JSON response: {json_err}")
|
||||||
|
self.logger.debug(f"BaseDebrid: Response content: {text[:200]}...")
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
wait_time = 2**attempt + 1
|
||||||
|
self.logger.info(f"BaseDebrid: Retrying in {wait_time} seconds...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def wait_for_ready_status(self, check_status_func, timeout=30, interval=5):
|
||||||
|
"""Async wait for ready status with polling."""
|
||||||
self.logger.info(f"BaseDebrid: Waiting for {timeout} seconds for caching.")
|
self.logger.info(f"BaseDebrid: Waiting for {timeout} seconds for caching.")
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
if check_status_func():
|
if await check_status_func():
|
||||||
self.logger.info("BaseDebrid: File is ready!")
|
self.logger.info("BaseDebrid: File is ready!")
|
||||||
return True
|
return True
|
||||||
time.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
self.logger.info(f"BaseDebrid: Waiting timed out.")
|
self.logger.info(f"BaseDebrid: Waiting timed out.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def download_torrent_file(self, download_url):
|
async def download_torrent_file(self, download_url):
|
||||||
response = requests.get(download_url)
|
"""Async download of torrent file."""
|
||||||
response.raise_for_status()
|
session = await self._get_session()
|
||||||
return response.content
|
timeout = aiohttp.ClientTimeout(total=30)
|
||||||
|
async with session.get(download_url, timeout=timeout) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
return await response.read()
|
||||||
|
|
||||||
def get_stream_link(self, query, ip=None):
|
async def get_stream_link(self, query, ip=None):
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def add_magnet(self, magnet, ip=None):
|
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
async def add_magnet(self, magnet, ip=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
|
||||||
14
stream_fusion/utils/debrid/debrid_exceptions.py
Normal file
14
stream_fusion/utils/debrid/debrid_exceptions.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
class DebridError(Exception):
|
||||||
|
def __init__(self, message: str, *, error_code: str = None, upstream_error_code: str = None):
|
||||||
|
self.error_code = error_code
|
||||||
|
self.upstream_error_code = upstream_error_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_keys(self) -> list:
|
||||||
|
keys = []
|
||||||
|
if self.upstream_error_code:
|
||||||
|
keys.append(self.upstream_error_code)
|
||||||
|
if self.error_code and self.error_code not in keys:
|
||||||
|
keys.append(self.error_code)
|
||||||
|
return keys
|
||||||
33
stream_fusion/utils/debrid/debridlink.py
Normal file
33
stream_fusion/utils/debrid/debridlink.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from stream_fusion.utils.debrid.stremthru import StremThru
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DebridLink(StremThru):
|
||||||
|
def __init__(self, config, session=None):
|
||||||
|
super().__init__(config, session)
|
||||||
|
self.name = "DebridLink"
|
||||||
|
self.extension = "DL"
|
||||||
|
|
||||||
|
# Récupérer la clé API de DebridLink
|
||||||
|
self.api_key = config.get("debridlink_api_key", "")
|
||||||
|
|
||||||
|
# Configurer StremThru pour utiliser DebridLink
|
||||||
|
self.set_store_credentials("debridlink", self.api_key)
|
||||||
|
|
||||||
|
async 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)
|
||||||
|
logger.debug(f"DebridLink (via StremThru): {len(results)} torrents en cache trouvés")
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def add_magnet(self, magnet, ip=None):
|
||||||
|
"""Ajoute un magnet à DebridLink via StremThru"""
|
||||||
|
result = await 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):
|
||||||
|
"""Génère un lien de streaming via StremThru"""
|
||||||
|
link = await super().get_stream_link(query, config, ip)
|
||||||
|
logger.debug(f"DebridLink (via StremThru): Lien de streaming généré: {link is not None}")
|
||||||
|
return link
|
||||||
33
stream_fusion/utils/debrid/easydebrid.py
Normal file
33
stream_fusion/utils/debrid/easydebrid.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from stream_fusion.utils.debrid.stremthru import StremThru
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
|
class EasyDebrid(StremThru):
|
||||||
|
def __init__(self, config, session=None):
|
||||||
|
super().__init__(config, session)
|
||||||
|
self.name = "EasyDebrid"
|
||||||
|
self.extension = "ED"
|
||||||
|
|
||||||
|
# Récupérer la clé API d'EasyDebrid
|
||||||
|
self.api_key = config.get("easydebrid_api_key", "")
|
||||||
|
|
||||||
|
# Configurer StremThru pour utiliser EasyDebrid
|
||||||
|
self.set_store_credentials("easydebrid", self.api_key)
|
||||||
|
|
||||||
|
async 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)
|
||||||
|
logger.debug(f"EasyDebrid (via StremThru): {len(results)} torrents en cache trouvés")
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def add_magnet(self, magnet, ip=None):
|
||||||
|
"""Ajoute un magnet à EasyDebrid via StremThru"""
|
||||||
|
result = await 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):
|
||||||
|
"""Génère un lien de streaming via StremThru"""
|
||||||
|
link = await super().get_stream_link(query, config, ip)
|
||||||
|
logger.debug(f"EasyDebrid (via StremThru): Lien de streaming généré: {link is not None}")
|
||||||
|
return link
|
||||||
|
|
@ -1,38 +1,124 @@
|
||||||
|
import aiohttp
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
|
|
||||||
from stream_fusion.utils.debrid.alldebrid import AllDebrid
|
from stream_fusion.utils.debrid.alldebrid import AllDebrid
|
||||||
from stream_fusion.utils.debrid.realdebrid import RealDebrid
|
from stream_fusion.utils.debrid.realdebrid import RealDebrid
|
||||||
from stream_fusion.utils.debrid.torbox import Torbox
|
from stream_fusion.utils.debrid.torbox import Torbox
|
||||||
from stream_fusion.utils.debrid.premiumize import Premiumize
|
from stream_fusion.utils.debrid.premiumize import Premiumize
|
||||||
|
from stream_fusion.utils.debrid.debridlink import DebridLink
|
||||||
|
from stream_fusion.utils.debrid.easydebrid import EasyDebrid
|
||||||
|
from stream_fusion.utils.debrid.offcloud import Offcloud
|
||||||
|
from stream_fusion.utils.debrid.pikpak import PikPak
|
||||||
|
from stream_fusion.utils.debrid.stremthru import StremThru
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
def get_all_debrid_services(config):
|
def get_all_debrid_services(config, session: aiohttp.ClientSession = None):
|
||||||
services = config['service']
|
services = config['service']
|
||||||
debrid_service = []
|
debrid_service = []
|
||||||
if not services:
|
if not services:
|
||||||
logger.error("No service configuration found in the config file.")
|
logger.error("No service configuration found in the config file.")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
use_stremthru = config.get('stremthru', False)
|
||||||
|
|
||||||
for service in services:
|
for service in services:
|
||||||
if service == "Real-Debrid":
|
if service == "Real-Debrid":
|
||||||
debrid_service.append(RealDebrid(config))
|
if use_stremthru:
|
||||||
logger.debug("Real-Debrid: service added to be use")
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
|
||||||
|
st.extension = "ST:RD"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("Real-Debrid (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(RealDebrid(config, session))
|
||||||
|
logger.debug("Real-Debrid: service added to be use")
|
||||||
|
|
||||||
if service == "AllDebrid":
|
if service == "AllDebrid":
|
||||||
debrid_service.append(AllDebrid(config))
|
if use_stremthru:
|
||||||
logger.debug("AllDebrid: service added to be use")
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
|
||||||
|
st.extension = "ST:AD"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("AllDebrid (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(AllDebrid(config, session))
|
||||||
|
logger.debug("AllDebrid: service added to be use")
|
||||||
|
|
||||||
if service == "TorBox":
|
if service == "TorBox":
|
||||||
debrid_service.append(Torbox(config))
|
if use_stremthru:
|
||||||
logger.debug("TorBox: service added to be use")
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("torbox", config.get("TBToken", ""))
|
||||||
|
st.extension = "ST:TB"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("TorBox (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(Torbox(config, session))
|
||||||
|
logger.debug("TorBox: service added to be use")
|
||||||
|
|
||||||
if service == "Premiumize":
|
if service == "Premiumize":
|
||||||
debrid_service.append(Premiumize(config))
|
if use_stremthru:
|
||||||
logger.debug("Premiumize: service added to be use")
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("premiumize", config.get("PMToken", ""))
|
||||||
|
st.extension = "ST:PM"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("Premiumize (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(Premiumize(config, session))
|
||||||
|
logger.debug("Premiumize: service added to be use")
|
||||||
|
|
||||||
|
if service == "Debrid-Link":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("debridlink", config.get("DLToken", ""))
|
||||||
|
st.extension = "ST:DL"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("Debrid-Link (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(DebridLink(config, session))
|
||||||
|
logger.debug("Debrid-Link: service added to be use")
|
||||||
|
|
||||||
|
if service == "EasyDebrid":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
|
||||||
|
st.extension = "ST:ED"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("EasyDebrid (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(EasyDebrid(config, session))
|
||||||
|
logger.debug("EasyDebrid: service added to be use")
|
||||||
|
|
||||||
|
if service == "Offcloud":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
|
||||||
|
st.extension = "ST:OC"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("Offcloud (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(Offcloud(config, session))
|
||||||
|
logger.debug("Offcloud: service added to be use")
|
||||||
|
|
||||||
|
if service == "PikPak":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
|
||||||
|
st.extension = "ST:PP"
|
||||||
|
debrid_service.append(st)
|
||||||
|
logger.debug("PikPak (via StremThru): service added to be use")
|
||||||
|
else:
|
||||||
|
debrid_service.append(PikPak(config, session))
|
||||||
|
logger.debug("PikPak: service added to be use")
|
||||||
|
|
||||||
if not debrid_service:
|
if not debrid_service:
|
||||||
raise HTTPException(status_code=500, detail="Invalid service configuration.")
|
raise HTTPException(status_code=500, detail="Invalid service configuration.")
|
||||||
|
|
||||||
return debrid_service
|
return debrid_service
|
||||||
|
|
||||||
def get_download_service(config):
|
|
||||||
|
def get_download_service(config, session: aiohttp.ClientSession = None):
|
||||||
if not settings.download_service:
|
if not settings.download_service:
|
||||||
service = config.get('debridDownloader')
|
service = config.get('debridDownloader')
|
||||||
if not service:
|
if not service:
|
||||||
|
|
@ -49,15 +135,57 @@ def get_download_service(config):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
service = settings.download_service
|
service = settings.download_service
|
||||||
|
|
||||||
|
use_stremthru = config.get('stremthru', False)
|
||||||
|
|
||||||
if service == "Real-Debrid":
|
if service == "Real-Debrid":
|
||||||
return RealDebrid(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
|
||||||
|
return st
|
||||||
|
return RealDebrid(config, session)
|
||||||
elif service == "AllDebrid":
|
elif service == "AllDebrid":
|
||||||
return AllDebrid(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
|
||||||
|
return st
|
||||||
|
return AllDebrid(config, session)
|
||||||
elif service == "TorBox":
|
elif service == "TorBox":
|
||||||
return Torbox(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("torbox", config.get("TBToken", ""))
|
||||||
|
return st
|
||||||
|
return Torbox(config, session)
|
||||||
elif service == "Premiumize":
|
elif service == "Premiumize":
|
||||||
return Premiumize(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("premiumize", config.get("PMToken", ""))
|
||||||
|
return st
|
||||||
|
return Premiumize(config, session)
|
||||||
|
elif service == "Debrid-Link":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("debridlink", config.get("DLToken", ""))
|
||||||
|
return st
|
||||||
|
return DebridLink(config, session)
|
||||||
|
elif service == "EasyDebrid":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
|
||||||
|
return st
|
||||||
|
return EasyDebrid(config, session)
|
||||||
|
elif service == "Offcloud":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
|
||||||
|
return st
|
||||||
|
return Offcloud(config, session)
|
||||||
|
elif service == "PikPak":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
|
||||||
|
return st
|
||||||
|
return PikPak(config, session)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Invalid download service: {service}")
|
logger.error(f"Invalid download service: {service}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -66,19 +194,62 @@ def get_download_service(config):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_debrid_service(config, service):
|
def get_debrid_service(config, service, session: aiohttp.ClientSession = None):
|
||||||
if not service:
|
if not service:
|
||||||
service = settings.download_service
|
service = settings.download_service
|
||||||
|
|
||||||
|
use_stremthru = config.get('stremthru', False)
|
||||||
|
|
||||||
if service == "RD":
|
if service == "RD":
|
||||||
return RealDebrid(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("realdebrid", config.get("RDToken", ""))
|
||||||
|
return st
|
||||||
|
return RealDebrid(config, session)
|
||||||
elif service == "AD":
|
elif service == "AD":
|
||||||
return AllDebrid(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("alldebrid", config.get("ADToken", ""))
|
||||||
|
return st
|
||||||
|
return AllDebrid(config, session)
|
||||||
elif service == "TB":
|
elif service == "TB":
|
||||||
return Torbox(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("torbox", config.get("TBToken", ""))
|
||||||
|
return st
|
||||||
|
return Torbox(config, session)
|
||||||
elif service == "PM":
|
elif service == "PM":
|
||||||
return Premiumize(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("premiumize", config.get("PMToken", ""))
|
||||||
|
return st
|
||||||
|
return Premiumize(config, session)
|
||||||
elif service == "DL":
|
elif service == "DL":
|
||||||
return get_download_service(config)
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("debridlink", config.get("DLToken", ""))
|
||||||
|
return st
|
||||||
|
return DebridLink(config, session)
|
||||||
|
elif service == "ED":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("easydebrid", config.get("EDToken", ""))
|
||||||
|
return st
|
||||||
|
return EasyDebrid(config, session)
|
||||||
|
elif service == "OC":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("offcloud", config.get("OCCredentials", ""))
|
||||||
|
return st
|
||||||
|
return Offcloud(config, session)
|
||||||
|
elif service == "PP":
|
||||||
|
if use_stremthru:
|
||||||
|
st = StremThru(config, session)
|
||||||
|
st.set_store_credentials("pikpak", config.get("PPCredentials", ""))
|
||||||
|
return st
|
||||||
|
return PikPak(config, session)
|
||||||
|
elif service == "ST":
|
||||||
|
return get_download_service(config, session)
|
||||||
else:
|
else:
|
||||||
logger.error("Invalid service configuration return by stremio in the query.")
|
logger.error("Invalid service configuration return by stremio in the query.")
|
||||||
raise HTTPException(status_code=500, detail="Invalid service configuration return by stremio.")
|
raise HTTPException(status_code=500, detail="Invalid service configuration return by stremio.")
|
||||||
|
|
|
||||||
34
stream_fusion/utils/debrid/offcloud.py
Normal file
34
stream_fusion/utils/debrid/offcloud.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from stream_fusion.utils.debrid.stremthru import StremThru
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
|
class Offcloud(StremThru):
|
||||||
|
def __init__(self, config, session=None):
|
||||||
|
super().__init__(config, session)
|
||||||
|
self.name = "Offcloud"
|
||||||
|
self.extension = "OC"
|
||||||
|
|
||||||
|
# Récupérer les identifiants Offcloud (email:password)
|
||||||
|
self.credentials = config.get("offcloud_credentials", "")
|
||||||
|
|
||||||
|
# Configurer StremThru pour utiliser Offcloud
|
||||||
|
self.set_store_credentials("offcloud", self.credentials)
|
||||||
|
|
||||||
|
async 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)
|
||||||
|
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):
|
||||||
|
"""Ajoute un magnet à Offcloud via StremThru"""
|
||||||
|
result = await 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):
|
||||||
|
"""Génère un lien de streaming via StremThru"""
|
||||||
|
link = await super().get_stream_link(query, config, ip)
|
||||||
|
logger.debug(f"Offcloud (via StremThru): Lien de streaming généré: {link is not None}")
|
||||||
|
return link
|
||||||
33
stream_fusion/utils/debrid/pikpak.py
Normal file
33
stream_fusion/utils/debrid/pikpak.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from stream_fusion.utils.debrid.stremthru import StremThru
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
|
class PikPak(StremThru):
|
||||||
|
def __init__(self, config, session=None):
|
||||||
|
super().__init__(config, session)
|
||||||
|
self.name = "PikPak"
|
||||||
|
self.extension = "PP"
|
||||||
|
|
||||||
|
# Récupérer les identifiants PikPak (email:password)
|
||||||
|
self.credentials = config.get("pikpak_credentials", "")
|
||||||
|
|
||||||
|
# Configurer StremThru pour utiliser PikPak
|
||||||
|
self.set_store_credentials("pikpak", self.credentials)
|
||||||
|
|
||||||
|
async 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)
|
||||||
|
logger.debug(f"PikPak (via StremThru): {len(results)} torrents en cache trouvés")
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def add_magnet(self, magnet, ip=None):
|
||||||
|
"""Ajoute un magnet à PikPak via StremThru"""
|
||||||
|
result = await 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):
|
||||||
|
"""Génère un lien de streaming via StremThru"""
|
||||||
|
link = await super().get_stream_link(query, config, ip)
|
||||||
|
logger.debug(f"PikPak (via StremThru): Lien de streaming généré: {link is not None}")
|
||||||
|
return link
|
||||||
|
|
@ -1,64 +1,66 @@
|
||||||
import json
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import time
|
||||||
|
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
|
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.utils.general import get_info_hash_from_magnet, season_episode_in_filename
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class Premiumize(BaseDebrid):
|
class Premiumize(BaseDebrid):
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||||
super().__init__(config)
|
super().__init__(config, session)
|
||||||
self.base_url = "https://www.premiumize.me/api"
|
self.base_url = "https://www.premiumize.me/api"
|
||||||
self.api_key = config.get('PMToken') or settings.pm_token
|
self.api_key = config.get('PMToken') or settings.pm_token
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
logger.error("No Premiumize API key found in config or settings")
|
logger.error("No Premiumize API key found in config or settings")
|
||||||
raise ValueError("Premiumize API key is required")
|
raise ValueError("Premiumize API key is required")
|
||||||
|
|
||||||
# Vérifier la validité du token
|
|
||||||
self._check_token()
|
|
||||||
|
|
||||||
def _check_token(self):
|
self._token_checked = False
|
||||||
|
|
||||||
|
async def _ensure_token_checked(self):
|
||||||
|
"""Lazy token check - called before first API operation"""
|
||||||
|
if not self._token_checked:
|
||||||
|
await self._check_token()
|
||||||
|
self._token_checked = True
|
||||||
|
|
||||||
|
async def _check_token(self):
|
||||||
"""Vérifier la validité du token en appelant l'API account/info"""
|
"""Vérifier la validité du token en appelant l'API account/info"""
|
||||||
url = f"{self.base_url}/account/info"
|
url = f"{self.base_url}/account/info"
|
||||||
response = self.json_response(
|
response = await self.json_response(
|
||||||
url,
|
url,
|
||||||
method='post',
|
method='post',
|
||||||
data={'apikey': self.api_key}
|
data={'apikey': self.api_key}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response or response.get("status") != "success":
|
if not response or response.get("status") != "success":
|
||||||
logger.error(f"Invalid Premiumize API key: {self.api_key}")
|
logger.error(f"Invalid Premiumize API key: {self.api_key}")
|
||||||
raise ValueError("Invalid Premiumize API key")
|
raise ValueError("Invalid Premiumize API key")
|
||||||
|
|
||||||
logger.info("Premiumize API key is valid")
|
logger.info("Premiumize API key is valid")
|
||||||
|
|
||||||
def add_magnet(self, magnet, ip=None):
|
async def add_magnet(self, magnet, ip=None):
|
||||||
|
await self._ensure_token_checked()
|
||||||
url = f"{self.base_url}/transfer/create?apikey={self.api_key}"
|
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)
|
info_hash = get_info_hash_from_magnet(magnet)
|
||||||
is_season_pack = self._check_if_season_pack(magnet)
|
is_season_pack = self._check_if_season_pack(magnet)
|
||||||
|
|
||||||
form = {
|
form = {
|
||||||
'src': magnet,
|
'src': magnet,
|
||||||
'folder_name': f"season_pack_{info_hash}" if is_season_pack else None
|
'folder_name': f"season_pack_{info_hash}" if is_season_pack else None
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.json_response(url, method='post', data=form)
|
response = await self.json_response(url, method='post', data=form)
|
||||||
|
|
||||||
if is_season_pack and response and response.get("status") == "success":
|
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")
|
transfer_id = response.get("id")
|
||||||
if transfer_id:
|
if transfer_id:
|
||||||
# Attendre que le transfert soit terminé
|
if await self._wait_for_season_pack(transfer_id):
|
||||||
if self._wait_for_season_pack(transfer_id):
|
folder_details = await self.get_folder_or_file_details(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"):
|
if folder_details and folder_details.get("content"):
|
||||||
# Trier les fichiers par taille pour prendre le plus gros fichier vidéo
|
video_files = [f for f in folder_details["content"]
|
||||||
video_files = [f for f in folder_details["content"]
|
|
||||||
if f.get("mime_type", "").startswith("video/")]
|
if f.get("mime_type", "").startswith("video/")]
|
||||||
if video_files:
|
if video_files:
|
||||||
largest_file = max(video_files, key=lambda x: x.get("size", 0))
|
largest_file = max(video_files, key=lambda x: x.get("size", 0))
|
||||||
|
|
@ -69,15 +71,14 @@ class Premiumize(BaseDebrid):
|
||||||
"link": largest_file.get("link"),
|
"link": largest_file.get("link"),
|
||||||
"stream_link": largest_file.get("stream_link")
|
"stream_link": largest_file.get("stream_link")
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _check_if_season_pack(self, magnet):
|
def _check_if_season_pack(self, magnet):
|
||||||
"""Vérifie si le magnet link correspond à un pack de saison"""
|
"""Vérifie si le magnet link correspond à un pack de saison"""
|
||||||
# Vérifie les patterns communs dans le nom du torrent
|
|
||||||
name = magnet.lower()
|
name = magnet.lower()
|
||||||
season_indicators = [
|
season_indicators = [
|
||||||
"complete.season",
|
"complete.season",
|
||||||
"season.complete",
|
"season.complete",
|
||||||
"s01.complete",
|
"s01.complete",
|
||||||
"saison.complete",
|
"saison.complete",
|
||||||
|
|
@ -92,46 +93,80 @@ class Premiumize(BaseDebrid):
|
||||||
]
|
]
|
||||||
return any(indicator in name for indicator in season_indicators)
|
return any(indicator in name for indicator in season_indicators)
|
||||||
|
|
||||||
def _wait_for_season_pack(self, transfer_id, timeout=300):
|
async def _wait_for_season_pack(self, transfer_id, timeout=300, max_retries=10):
|
||||||
"""Attend que tous les fichiers d'un pack de saison soient disponibles"""
|
"""Attend que tous les fichiers d'un pack de saison soient disponibles"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
while time.time() - start_time < timeout:
|
retry_count = 0
|
||||||
transfer_info = self.get_folder_or_file_details(transfer_id)
|
|
||||||
if transfer_info and transfer_info.get("status") == "success":
|
while time.time() - start_time < timeout and retry_count < max_retries:
|
||||||
# Vérifier si des fichiers vidéo sont présents
|
try:
|
||||||
if transfer_info.get("content"):
|
transfer_info = await self.get_folder_or_file_details(transfer_id)
|
||||||
video_files = [f for f in transfer_info["content"]
|
|
||||||
if f.get("mime_type", "").startswith("video/")]
|
if transfer_info and transfer_info.get("status") == "success":
|
||||||
if video_files:
|
if transfer_info.get("content"):
|
||||||
return True
|
video_files = [f for f in transfer_info["content"]
|
||||||
time.sleep(5)
|
if f.get("mime_type", "").startswith("video/")]
|
||||||
|
if video_files:
|
||||||
|
logger.info(f"Season pack ready with {len(video_files)} video files")
|
||||||
|
return True
|
||||||
|
|
||||||
|
retry_count += 1
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
logger.debug(f"Waiting for season pack: {elapsed:.1f}s elapsed, retry {retry_count}/{max_retries}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error waiting for season pack: {e}")
|
||||||
|
retry_count += 1
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
logger.warning(f"Season pack timeout after {time.time() - start_time:.1f}s or {retry_count} retries")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def add_torrent(self, torrent_file):
|
async def add_torrent(self, torrent_file):
|
||||||
|
await self._ensure_token_checked()
|
||||||
url = f"{self.base_url}/transfer/create?apikey={self.api_key}"
|
url = f"{self.base_url}/transfer/create?apikey={self.api_key}"
|
||||||
form = {'file': torrent_file}
|
form = {'file': torrent_file}
|
||||||
return self.json_response(url, method='post', data=form)
|
return await self.json_response(url, method='post', data=form)
|
||||||
|
|
||||||
def list_transfers(self):
|
async def list_transfers(self):
|
||||||
|
await self._ensure_token_checked()
|
||||||
url = f"{self.base_url}/transfer/list?apikey={self.api_key}"
|
url = f"{self.base_url}/transfer/list?apikey={self.api_key}"
|
||||||
return self.json_response(url)
|
return await self.json_response(url)
|
||||||
|
|
||||||
def get_folder_or_file_details(self, item_id, is_folder=True):
|
async def get_folder_or_file_details(self, item_id, is_folder=True):
|
||||||
if is_folder:
|
"""Get folder or file details"""
|
||||||
logger.info(f"Getting folder details with id: {item_id}")
|
await self._ensure_token_checked()
|
||||||
url = f"{self.base_url}/folder/list?id={item_id}&apikey={self.api_key}"
|
try:
|
||||||
else:
|
if is_folder:
|
||||||
logger.info(f"Getting file details with id: {item_id}")
|
logger.debug(f"Getting folder details with id: {item_id}")
|
||||||
url = f"{self.base_url}/item/details?id={item_id}&apikey={self.api_key}"
|
url = f"{self.base_url}/folder/list?id={item_id}&apikey={self.api_key}"
|
||||||
return self.json_response(url)
|
else:
|
||||||
|
logger.debug(f"Getting file details with id: {item_id}")
|
||||||
|
url = f"{self.base_url}/item/details?id={item_id}&apikey={self.api_key}"
|
||||||
|
|
||||||
def get_availability(self, hash):
|
response = await self.json_response(url)
|
||||||
|
|
||||||
|
if response is None:
|
||||||
|
logger.warning(f"No response from Premiumize API for item {item_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except asyncio.TimeoutError as e:
|
||||||
|
logger.error(f"Timeout getting details for item {item_id}: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting details for item {item_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_availability(self, hash):
|
||||||
"""Get availability for a single hash"""
|
"""Get availability for a single hash"""
|
||||||
|
await self._ensure_token_checked()
|
||||||
if not hash:
|
if not hash:
|
||||||
return {"transcoded": [False]}
|
return {"transcoded": [False]}
|
||||||
|
|
||||||
url = f"{self.base_url}/cache/check?apikey={self.api_key}&items[]={hash}"
|
url = f"{self.base_url}/cache/check?apikey={self.api_key}&items[]={hash}"
|
||||||
response = self.json_response(url)
|
response = await self.json_response(url)
|
||||||
|
|
||||||
if not response or response.get("status") != "success":
|
if not response or response.get("status") != "success":
|
||||||
logger.error("Invalid response from Premiumize API")
|
logger.error("Invalid response from Premiumize API")
|
||||||
|
|
@ -141,21 +176,17 @@ class Premiumize(BaseDebrid):
|
||||||
"transcoded": response.get("transcoded", [False])
|
"transcoded": response.get("transcoded", [False])
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||||
"""Get availability for multiple hashes or magnets"""
|
"""Get availability for multiple hashes or magnets"""
|
||||||
|
await self._ensure_token_checked()
|
||||||
if not hashes_or_magnets:
|
if not hashes_or_magnets:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
logger.info(f"Checking availability for {len(hashes_or_magnets)} items")
|
logger.info(f"Checking availability for {len(hashes_or_magnets)} items")
|
||||||
logger.debug(f"Using Premiumize API key: {self.api_key}")
|
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"
|
url = f"{self.base_url}/cache/check"
|
||||||
response = self.json_response(
|
response = await self.json_response(
|
||||||
url,
|
url,
|
||||||
method='post',
|
method='post',
|
||||||
data={
|
data={
|
||||||
|
|
@ -163,49 +194,45 @@ class Premiumize(BaseDebrid):
|
||||||
'items[]': hashes_or_magnets
|
'items[]': hashes_or_magnets
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Raw Premiumize response: {response}")
|
logger.info(f"Raw Premiumize response: {response}")
|
||||||
|
|
||||||
if not response or response.get("status") != "success":
|
if not response or response.get("status") != "success":
|
||||||
logger.error("Invalid response from Premiumize API")
|
logger.error("Invalid response from Premiumize API")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Format response to match expected structure
|
|
||||||
result = {}
|
result = {}
|
||||||
for i, hash_or_magnet in enumerate(hashes_or_magnets):
|
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
|
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
|
filename = None
|
||||||
if isinstance(response.get("filename", []), list) and i < len(response["filename"]):
|
if isinstance(response.get("filename", []), list) and i < len(response["filename"]):
|
||||||
filename = response["filename"][i]
|
filename = response["filename"][i]
|
||||||
|
|
||||||
# Récupérer la taille du fichier et la convertir en entier
|
|
||||||
filesize = 0
|
filesize = 0
|
||||||
if isinstance(response.get("filesize", []), list) and i < len(response["filesize"]):
|
if isinstance(response.get("filesize", []), list) and i < len(response["filesize"]):
|
||||||
try:
|
try:
|
||||||
filesize = int(response["filesize"][i]) if response["filesize"][i] is not None else 0
|
filesize = int(response["filesize"][i]) if response["filesize"][i] is not None else 0
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
filesize = 0
|
filesize = 0
|
||||||
|
|
||||||
result[hash_or_magnet] = {
|
result[hash_or_magnet] = {
|
||||||
"transcoded": is_available,
|
"transcoded": is_available,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"filesize": filesize
|
"filesize": filesize
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Formatted response: {result}")
|
logger.info(f"Formatted response: {result}")
|
||||||
logger.info(f"Got availability for {len(result)} items")
|
logger.info(f"Got availability for {len(result)} items")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def start_background_caching(self, magnet, query=None):
|
async def start_background_caching(self, magnet, query=None):
|
||||||
"""Start caching a magnet link in the background."""
|
"""Start caching a magnet link in the background."""
|
||||||
|
await self._ensure_token_checked()
|
||||||
logger.info(f"Starting background caching for magnet")
|
logger.info(f"Starting background caching for magnet")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create a transfer without waiting for completion
|
response = await self.json_response(
|
||||||
response = self.json_response(
|
|
||||||
f"{self.base_url}/transfer/create",
|
f"{self.base_url}/transfer/create",
|
||||||
method="post",
|
method="post",
|
||||||
data={"apikey": self.api_key, "src": magnet}
|
data={"apikey": self.api_key, "src": magnet}
|
||||||
|
|
@ -226,130 +253,145 @@ class Premiumize(BaseDebrid):
|
||||||
logger.error(f"Error starting background caching: {str(e)}")
|
logger.error(f"Error starting background caching: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_stream_link(self, query, config, ip=None):
|
async def get_stream_link(self, query, config=None, ip=None, global_timeout=30):
|
||||||
"""Get a stream link for a magnet link"""
|
"""Get a stream link for a magnet link with timeout protection"""
|
||||||
|
await self._ensure_token_checked()
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
if not query:
|
if not query:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info("Getting stream link for magnet")
|
|
||||||
|
|
||||||
# Vérifier si c'est une série et extraire la saison/épisode
|
|
||||||
season = None
|
|
||||||
episode = None
|
|
||||||
if isinstance(query, dict):
|
|
||||||
magnet = query.get("magnet")
|
|
||||||
if not magnet:
|
|
||||||
logger.error("No magnet link in query")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Vérifier si c'est une série
|
|
||||||
if query.get("type") == "series" and query.get("season") and query.get("episode"):
|
|
||||||
season = query["season"].replace("S", "") if isinstance(query["season"], str) else query["season"]
|
|
||||||
episode = query["episode"].replace("E", "") if isinstance(query["episode"], str) else query["episode"]
|
|
||||||
try:
|
|
||||||
season = int(season)
|
|
||||||
episode = int(episode)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
logger.error(f"Invalid season/episode format: {season}/{episode}")
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
magnet = query
|
|
||||||
|
|
||||||
# Essayer d'abord le téléchargement direct
|
logger.info(f"Getting stream link for magnet (global timeout: {global_timeout}s)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.json_response(
|
season = None
|
||||||
f"{self.base_url}/transfer/directdl",
|
episode = None
|
||||||
method="post",
|
if isinstance(query, dict):
|
||||||
data={"apikey": self.api_key, "src": magnet}
|
magnet = query.get("magnet")
|
||||||
)
|
if not magnet:
|
||||||
|
logger.error("No magnet link in query")
|
||||||
|
return None
|
||||||
|
|
||||||
if response and response.get("status") == "success":
|
if query.get("type") == "series" and query.get("season") and query.get("episode"):
|
||||||
logger.info("Got direct download response")
|
season = query["season"].replace("S", "") if isinstance(query["season"], str) else query["season"]
|
||||||
if "content" in response and response["content"]:
|
episode = query["episode"].replace("E", "") if isinstance(query["episode"], str) else query["episode"]
|
||||||
# Si c'est une série, chercher l'épisode correspondant
|
try:
|
||||||
if season is not None and episode is not None:
|
season = int(season)
|
||||||
matching_files = []
|
episode = int(episode)
|
||||||
for file in response["content"]:
|
except (ValueError, TypeError):
|
||||||
filename = file.get("path", "").split("/")[-1]
|
logger.error(f"Invalid season/episode format: {season}/{episode}")
|
||||||
if season_episode_in_filename(filename, season, episode):
|
return None
|
||||||
matching_files.append(file)
|
else:
|
||||||
|
magnet = query
|
||||||
if matching_files:
|
|
||||||
# Prendre le plus gros fichier parmi ceux qui correspondent
|
def check_timeout(operation_name):
|
||||||
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
|
elapsed = time.time() - start_time
|
||||||
stream_link = selected_file.get("stream_link") or selected_file.get("link")
|
if elapsed > global_timeout:
|
||||||
|
logger.error(f"{operation_name} exceeded global timeout ({elapsed:.1f}s > {global_timeout}s)")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Essayer d'abord le téléchargement direct
|
||||||
|
if check_timeout("Direct download"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.json_response(
|
||||||
|
f"{self.base_url}/transfer/directdl",
|
||||||
|
method="post",
|
||||||
|
data={"apikey": self.api_key, "src": magnet}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response and response.get("status") == "success":
|
||||||
|
logger.info("Got direct download response")
|
||||||
|
if "content" in response and response["content"]:
|
||||||
|
if season is not None and episode is not None:
|
||||||
|
matching_files = []
|
||||||
|
for file in response["content"]:
|
||||||
|
filename = file.get("path", "").split("/")[-1]
|
||||||
|
if season_episode_in_filename(filename, season, episode):
|
||||||
|
matching_files.append(file)
|
||||||
|
|
||||||
|
if matching_files:
|
||||||
|
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
|
||||||
|
stream_link = selected_file.get("stream_link") or selected_file.get("link")
|
||||||
|
if stream_link:
|
||||||
|
logger.info(f"Found matching episode stream link: {stream_link[:50]}...")
|
||||||
|
return stream_link
|
||||||
|
|
||||||
|
video_files = [f for f in response["content"]
|
||||||
|
if isinstance(f.get("path", ""), str) and
|
||||||
|
f.get("path", "").lower().endswith((".mkv", ".mp4", ".avi", ".m4v"))]
|
||||||
|
|
||||||
|
if video_files:
|
||||||
|
largest_file = max(video_files, key=lambda x: x.get("size", 0))
|
||||||
|
stream_link = largest_file.get("stream_link") or largest_file.get("link")
|
||||||
if stream_link:
|
if stream_link:
|
||||||
logger.info(f"Found matching episode stream link: {stream_link[:50]}...")
|
logger.info(f"Found stream link: {stream_link[:50]}...")
|
||||||
return stream_link
|
return stream_link
|
||||||
|
elif response.get("location"):
|
||||||
# Si ce n'est pas une série ou si aucun fichier ne correspond,
|
logger.info(f"Found direct location: {response['location'][:50]}...")
|
||||||
# prendre le plus gros fichier vidéo
|
return response["location"]
|
||||||
video_files = [f for f in response["content"]
|
|
||||||
if isinstance(f.get("path", ""), str) and
|
except Exception as e:
|
||||||
f.get("path", "").lower().endswith((".mkv", ".mp4", ".avi", ".m4v"))]
|
logger.warning(f"Error in direct download: {str(e)}")
|
||||||
|
|
||||||
if video_files:
|
if check_timeout("Add magnet"):
|
||||||
largest_file = max(video_files, key=lambda x: x.get("size", 0))
|
return None
|
||||||
stream_link = largest_file.get("stream_link") or largest_file.get("link")
|
|
||||||
if stream_link:
|
response = await self.add_magnet(magnet, ip)
|
||||||
logger.info(f"Found stream link: {stream_link[:50]}...")
|
if not response or response.get("status") != "success":
|
||||||
return stream_link
|
logger.error("Failed to add magnet")
|
||||||
elif response.get("location"):
|
return None
|
||||||
logger.info(f"Found direct location: {response['location'][:50]}...")
|
|
||||||
return response["location"]
|
transfer_id = response.get("id")
|
||||||
|
if not transfer_id:
|
||||||
|
logger.error("No transfer ID in response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if check_timeout("Wait for transfer"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
remaining_timeout = global_timeout - (time.time() - start_time)
|
||||||
|
if not await self._wait_for_season_pack(transfer_id, timeout=int(remaining_timeout)):
|
||||||
|
logger.error("Transfer timed out")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if check_timeout("Get folder details"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
folder_details = await self.get_folder_or_file_details(transfer_id)
|
||||||
|
if not folder_details or not folder_details.get("content"):
|
||||||
|
logger.error("No content in folder details")
|
||||||
|
return None
|
||||||
|
|
||||||
|
video_files = [f for f in folder_details["content"]
|
||||||
|
if isinstance(f.get("mime_type", ""), str) and
|
||||||
|
f.get("mime_type", "").startswith("video/")]
|
||||||
|
|
||||||
|
if season is not None and episode is not None:
|
||||||
|
matching_files = []
|
||||||
|
for file in video_files:
|
||||||
|
filename = file.get("name", "")
|
||||||
|
if season_episode_in_filename(filename, season, episode):
|
||||||
|
matching_files.append(file)
|
||||||
|
|
||||||
|
if matching_files:
|
||||||
|
selected_file = max(matching_files, key=lambda x: x.get("size", 0))
|
||||||
|
logger.info(f"Selected matching episode file: {selected_file.get('name')}")
|
||||||
|
return selected_file.get("stream_link")
|
||||||
|
|
||||||
|
if video_files:
|
||||||
|
selected_file = max(video_files, key=lambda x: x.get("size", 0))
|
||||||
|
logger.info(f"Selected largest video file: {selected_file.get('name')}")
|
||||||
|
return selected_file.get("stream_link")
|
||||||
|
|
||||||
|
logger.error("No suitable video file found")
|
||||||
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in direct download: {str(e)}")
|
logger.error(f"Exception in get_stream_link: {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
|
return None
|
||||||
|
finally:
|
||||||
# Récupérer l'ID du transfert
|
elapsed = time.time() - start_time
|
||||||
transfer_id = response.get("id")
|
logger.debug(f"get_stream_link completed in {elapsed:.1f}s")
|
||||||
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 re
|
||||||
import time
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
import requests
|
|
||||||
|
|
||||||
from stream_fusion.services.rd_conn.token_manager import RDTokenManager
|
from stream_fusion.services.rd_conn.token_manager import RDTokenManager
|
||||||
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
|
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
|
||||||
|
|
@ -17,8 +17,8 @@ from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
class RealDebrid(BaseDebrid):
|
class RealDebrid(BaseDebrid):
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||||
super().__init__(config)
|
super().__init__(config, session)
|
||||||
self.base_url = f"{settings.rd_base_url}/{settings.rd_api_version}/"
|
self.base_url = f"{settings.rd_base_url}/{settings.rd_api_version}/"
|
||||||
if not settings.rd_unique_account:
|
if not settings.rd_unique_account:
|
||||||
self.token_manager = RDTokenManager(config)
|
self.token_manager = RDTokenManager(config)
|
||||||
|
|
@ -47,42 +47,57 @@ class RealDebrid(BaseDebrid):
|
||||||
else:
|
else:
|
||||||
return {"Authorization": f"Bearer {self.token_manager.get_access_token()}"}
|
return {"Authorization": f"Bearer {self.token_manager.get_access_token()}"}
|
||||||
|
|
||||||
def add_magnet(self, magnet, ip=None):
|
async def add_magnet(self, magnet, ip=None):
|
||||||
url = f"{self.base_url}torrents/addMagnet"
|
url = f"{self.base_url}torrents/addMagnet"
|
||||||
data = {"magnet": magnet}
|
data = {"magnet": magnet}
|
||||||
logger.info(f"Real-Debrid: Adding magnet: {magnet}")
|
logger.info(f"Real-Debrid: Adding magnet: {magnet}")
|
||||||
return self.json_response(
|
try:
|
||||||
url, method="post", headers=self.get_headers(), data=data
|
return await self.json_response(
|
||||||
)
|
url, method="post", headers=self.get_headers(), data=data
|
||||||
|
)
|
||||||
|
except HTTPException as e:
|
||||||
|
if e.status_code == 451:
|
||||||
|
logger.error(f"Real-Debrid: Torrent banned (451): {magnet}")
|
||||||
|
raise
|
||||||
|
raise
|
||||||
|
|
||||||
def add_torrent(self, torrent_file):
|
async def add_torrent(self, torrent_file):
|
||||||
url = f"{self.base_url}torrents/addTorrent"
|
url = f"{self.base_url}torrents/addTorrent"
|
||||||
return self.json_response(
|
try:
|
||||||
url, method="put", headers=self.get_headers(), data=torrent_file
|
return await self.json_response(
|
||||||
)
|
url, method="put", headers=self.get_headers(), data=torrent_file
|
||||||
|
)
|
||||||
|
except HTTPException as e:
|
||||||
|
if e.status_code == 451:
|
||||||
|
logger.error(f"Real-Debrid: Torrent banned (451)")
|
||||||
|
raise
|
||||||
|
raise
|
||||||
|
|
||||||
def delete_torrent(self, id):
|
async def delete_torrent(self, id):
|
||||||
url = f"{self.base_url}torrents/delete/{id}"
|
url = f"{self.base_url}torrents/delete/{id}"
|
||||||
return self.json_response(url, method="delete", headers=self.get_headers())
|
return await self.json_response(url, method="delete", headers=self.get_headers())
|
||||||
|
|
||||||
def get_torrent_info(self, torrent_id):
|
async def get_torrent_info(self, torrent_id):
|
||||||
logger.info(f"Real-Debrid: Getting torrent info for ID: {torrent_id}")
|
logger.info(f"Real-Debrid: Getting torrent info for ID: {torrent_id}")
|
||||||
url = f"{self.base_url}torrents/info/{torrent_id}"
|
url = f"{self.base_url}torrents/info/{torrent_id}"
|
||||||
torrent_info = self.json_response(url, headers=self.get_headers())
|
torrent_info = await self.json_response(url, headers=self.get_headers())
|
||||||
if not torrent_info or "files" not in torrent_info:
|
if not torrent_info or "files" not in torrent_info:
|
||||||
return None
|
return None
|
||||||
return torrent_info
|
return torrent_info
|
||||||
|
|
||||||
def select_files(self, torrent_id, file_id):
|
async def select_files(self, torrent_id, file_id):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Real-Debrid: Selecting file(s): {file_id} for torrent ID: {torrent_id}"
|
f"Real-Debrid: Selecting file(s): {file_id} for torrent ID: {torrent_id}"
|
||||||
)
|
)
|
||||||
self._torrent_rate_limit()
|
await self._torrent_rate_limit()
|
||||||
url = f"{self.base_url}torrents/selectFiles/{torrent_id}"
|
url = f"{self.base_url}torrents/selectFiles/{torrent_id}"
|
||||||
data = {"files": str(file_id)}
|
data = {"files": str(file_id)}
|
||||||
requests.post(url, headers=self.get_headers(), data=data)
|
session = await self._get_session()
|
||||||
|
timeout = aiohttp.ClientTimeout(total=30)
|
||||||
|
async with session.post(url, headers=self.get_headers(), data=data, timeout=timeout) as response:
|
||||||
|
pass # Just need to make the request
|
||||||
|
|
||||||
def unrestrict_link(self, link):
|
async def unrestrict_link(self, link):
|
||||||
url = f"{self.base_url}unrestrict/link"
|
url = f"{self.base_url}unrestrict/link"
|
||||||
data = {"link": link}
|
data = {"link": link}
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
|
|
@ -90,52 +105,53 @@ class RealDebrid(BaseDebrid):
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = self.json_response(url, method="post", headers=self.get_headers(), data=data)
|
response = await self.json_response(url, method="post", headers=self.get_headers(), data=data)
|
||||||
if response and "download" in response:
|
if response and "download" in response:
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Real-Debrid: Unexpected response when unrestricting link: {response}")
|
logger.warning(f"Real-Debrid: Unexpected response when unrestricting link: {response}")
|
||||||
except requests.RequestException as e:
|
except Exception as e:
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
logger.warning(f"Real-Debrid: Error unrestricting link (attempt {attempt + 1}/{max_retries}): {str(e)}")
|
logger.warning(f"Real-Debrid: Error unrestricting link (attempt {attempt + 1}/{max_retries}): {str(e)}")
|
||||||
time.sleep(retry_delay)
|
await asyncio.sleep(retry_delay)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Real-Debrid: Failed to unrestrict link after {max_retries} attempts: {str(e)}")
|
logger.error(f"Real-Debrid: Failed to unrestrict link after {max_retries} attempts: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def is_already_added(self, magnet):
|
async def is_already_added(self, magnet):
|
||||||
hash = magnet.split("urn:btih:")[1].split("&")[0].lower()
|
hash = magnet.split("urn:btih:")[1].split("&")[0].lower()
|
||||||
url = f"{self.base_url}torrents"
|
url = f"{self.base_url}torrents"
|
||||||
torrents = self.json_response(url, headers=self.get_headers())
|
torrents = await self.json_response(url, headers=self.get_headers())
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
if torrent["hash"].lower() == hash:
|
if torrent["hash"].lower() == hash:
|
||||||
return torrent["id"]
|
return torrent["id"]
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def wait_for_link(self, torrent_id, timeout=60, interval=5):
|
async def wait_for_link(self, torrent_id, timeout=60, interval=5):
|
||||||
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
torrent_info = self.get_torrent_info(torrent_id)
|
torrent_info = await self.get_torrent_info(torrent_id)
|
||||||
if (
|
if (
|
||||||
torrent_info
|
torrent_info
|
||||||
and "links" in torrent_info
|
and "links" in torrent_info
|
||||||
and len(torrent_info["links"]) > 0
|
and len(torrent_info["links"]) > 0
|
||||||
):
|
):
|
||||||
return torrent_info["links"]
|
return torrent_info["links"]
|
||||||
time.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||||
self._torrent_rate_limit()
|
await self._torrent_rate_limit()
|
||||||
if len(hashes_or_magnets) == 0:
|
if len(hashes_or_magnets) == 0:
|
||||||
logger.info("Real-Debrid: No hashes to be sent.")
|
logger.info("Real-Debrid: No hashes to be sent.")
|
||||||
return dict()
|
return dict()
|
||||||
url = f"{self.base_url}torrents/instantAvailability/{'/'.join(hashes_or_magnets)}"
|
url = f"{self.base_url}torrents/instantAvailability/{'/'.join(hashes_or_magnets)}"
|
||||||
return self.json_response(url, headers=self.get_headers())
|
return await self.json_response(url, headers=self.get_headers())
|
||||||
|
|
||||||
def get_stream_link(self, query, config, ip=None):
|
async def get_stream_link(self, query, config=None, ip=None):
|
||||||
# Extract query parameters
|
# Extract query parameters
|
||||||
magnet = query["magnet"]
|
magnet = query["magnet"]
|
||||||
stream_type = query["type"]
|
stream_type = query["type"]
|
||||||
|
|
@ -147,42 +163,42 @@ class RealDebrid(BaseDebrid):
|
||||||
logger.info(f"Real-Debrid: Getting stream link for {stream_type} with hash: {info_hash}")
|
logger.info(f"Real-Debrid: Getting stream link for {stream_type} with hash: {info_hash}")
|
||||||
|
|
||||||
# Check for cached torrents
|
# Check for cached torrents
|
||||||
cached_torrent_ids = self._get_cached_torrent_ids(info_hash)
|
cached_torrent_ids = await self._get_cached_torrent_ids(info_hash)
|
||||||
logger.info(f"Real-Debrid: Found {len(cached_torrent_ids)} cached torrents with hash: {info_hash}")
|
logger.info(f"Real-Debrid: Found {len(cached_torrent_ids)} cached torrents with hash: {info_hash}")
|
||||||
|
|
||||||
torrent_id = None
|
torrent_id = None
|
||||||
if cached_torrent_ids:
|
if cached_torrent_ids:
|
||||||
torrent_info = self._get_cached_torrent_info(cached_torrent_ids, file_index, season, episode, stream_type)
|
torrent_info = await self._get_cached_torrent_info(cached_torrent_ids, file_index, season, episode, stream_type)
|
||||||
if torrent_info:
|
if torrent_info:
|
||||||
torrent_id = torrent_info["id"]
|
torrent_id = torrent_info["id"]
|
||||||
logger.info(f"Real-Debrid: Found cached torrent with ID: {torrent_id}")
|
logger.info(f"Real-Debrid: Found cached torrent with ID: {torrent_id}")
|
||||||
|
|
||||||
# If the torrent is not in cache, add it
|
# If the torrent is not in cache, add it
|
||||||
if torrent_id is None:
|
if torrent_id is None:
|
||||||
torrent_id = self.add_magnet_or_torrent_and_select(query, ip)
|
torrent_id = await self.add_magnet_or_torrent_and_select(query, ip)
|
||||||
if not torrent_id:
|
if not torrent_id:
|
||||||
logger.error("Real-Debrid: Failed to add or find torrent.")
|
logger.error("Real-Debrid: Failed to add or find torrent.")
|
||||||
raise HTTPException(status_code=500, detail="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}")
|
logger.info(f"Real-Debrid: Waiting for link(s) to be ready for torrent ID: {torrent_id}")
|
||||||
links = self.wait_for_link(torrent_id, timeout=20) # Increased timeout to allow for slow servers
|
links = await self.wait_for_link(torrent_id, timeout=20)
|
||||||
if links is None:
|
if links is None:
|
||||||
logger.warning("Real-Debrid: No links available after waiting. Returning NO_CACHE_VIDEO_URL.")
|
logger.warning("Real-Debrid: No links available after waiting. Returning NO_CACHE_VIDEO_URL.")
|
||||||
return settings.no_cache_video_url
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
# Refresh torrent info to ensure we have the latest data
|
# Refresh torrent info to ensure we have the latest data
|
||||||
torrent_info = self.get_torrent_info(torrent_id)
|
torrent_info = await self.get_torrent_info(torrent_id)
|
||||||
|
|
||||||
# Select the appropriate link
|
# Select the appropriate link
|
||||||
if len(links) > 1:
|
if len(links) > 1:
|
||||||
logger.info("Real-Debrid: Finding appropriate link")
|
logger.info("Real-Debrid: Finding appropriate link")
|
||||||
download_link = self._find_appropriate_link(torrent_info, links, file_index, season, episode)
|
download_link = await self._find_appropriate_link(torrent_info, links, file_index, season, episode)
|
||||||
else:
|
else:
|
||||||
download_link = links[0]
|
download_link = links[0]
|
||||||
|
|
||||||
# Unrestrict the link
|
# Unrestrict the link
|
||||||
logger.info(f"Real-Debrid: Unrestricting the download link: {download_link}")
|
logger.info(f"Real-Debrid: Unrestricting the download link: {download_link}")
|
||||||
unrestrict_response = self.unrestrict_link(download_link)
|
unrestrict_response = await self.unrestrict_link(download_link)
|
||||||
if not unrestrict_response or "download" not in unrestrict_response:
|
if not unrestrict_response or "download" not in unrestrict_response:
|
||||||
logger.error("Real-Debrid: Failed to unrestrict link.")
|
logger.error("Real-Debrid: Failed to unrestrict link.")
|
||||||
return None
|
return None
|
||||||
|
|
@ -190,10 +206,10 @@ class RealDebrid(BaseDebrid):
|
||||||
logger.info(f"Real-Debrid: Got download link: {unrestrict_response['download']}")
|
logger.info(f"Real-Debrid: Got download link: {unrestrict_response['download']}")
|
||||||
return unrestrict_response["download"]
|
return unrestrict_response["download"]
|
||||||
|
|
||||||
def _get_cached_torrent_ids(self, info_hash):
|
async def _get_cached_torrent_ids(self, info_hash):
|
||||||
self._torrent_rate_limit()
|
await self._torrent_rate_limit()
|
||||||
url = f"{self.base_url}torrents"
|
url = f"{self.base_url}torrents"
|
||||||
torrents = self.json_response(url, headers=self.get_headers())
|
torrents = await self.json_response(url, headers=self.get_headers())
|
||||||
|
|
||||||
logger.info(f"Real-Debrid: Searching user's downloads for hash: {info_hash}")
|
logger.info(f"Real-Debrid: Searching user's downloads for hash: {info_hash}")
|
||||||
torrent_ids = [
|
torrent_ids = [
|
||||||
|
|
@ -203,11 +219,11 @@ class RealDebrid(BaseDebrid):
|
||||||
]
|
]
|
||||||
return torrent_ids
|
return torrent_ids
|
||||||
|
|
||||||
def _get_cached_torrent_info(
|
async def _get_cached_torrent_info(
|
||||||
self, cached_ids, file_index, season, episode, stream_type
|
self, cached_ids, file_index, season, episode, stream_type
|
||||||
):
|
):
|
||||||
for cached_torrent_id in cached_ids:
|
for cached_torrent_id in cached_ids:
|
||||||
cached_torrent_info = self.get_torrent_info(cached_torrent_id)
|
cached_torrent_info = await self.get_torrent_info(cached_torrent_id)
|
||||||
if self._torrent_contains_file(
|
if self._torrent_contains_file(
|
||||||
cached_torrent_info, file_index, season, episode, stream_type
|
cached_torrent_info, file_index, season, episode, stream_type
|
||||||
):
|
):
|
||||||
|
|
@ -236,37 +252,37 @@ class RealDebrid(BaseDebrid):
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None):
|
||||||
if torrent_download is None:
|
if torrent_download is None:
|
||||||
logger.info("Real-Debrid: Adding magnet")
|
logger.info("Real-Debrid: Adding magnet")
|
||||||
magnet_response = self.add_magnet(magnet)
|
magnet_response = await self.add_magnet(magnet)
|
||||||
logger.info(f"Real-Debrid: Add magnet response: {magnet_response}")
|
logger.info(f"Real-Debrid: Add magnet response: {magnet_response}")
|
||||||
|
|
||||||
if not magnet_response or "id" not in magnet_response:
|
if not magnet_response or "id" not in magnet_response:
|
||||||
logger.error("Real-Debrid: Failed to add magnet.")
|
logger.error("Real-Debrid: Failed to add magnet.")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail="Real-Debrid: Failed to add magnet."
|
status_code=451, detail="Real-Debrid: Torrent banned or unavailable for legal reasons."
|
||||||
)
|
)
|
||||||
|
|
||||||
torrent_id = magnet_response["id"]
|
torrent_id = magnet_response["id"]
|
||||||
else:
|
else:
|
||||||
logger.info("Real-Debrid: Downloading and adding torrent file")
|
logger.info("Real-Debrid: Downloading and adding torrent file")
|
||||||
torrent_file = self.download_torrent_file(torrent_download)
|
torrent_file = await self.download_torrent_file(torrent_download)
|
||||||
upload_response = self.add_torrent(torrent_file)
|
upload_response = await self.add_torrent(torrent_file)
|
||||||
logger.info(f"Real-Debrid: Add torrent file response: {upload_response}")
|
logger.info(f"Real-Debrid: Add torrent file response: {upload_response}")
|
||||||
|
|
||||||
if not upload_response or "id" not in upload_response:
|
if not upload_response or "id" not in upload_response:
|
||||||
logger.error("Real-Debrid: Failed to add torrent file.")
|
logger.error("Real-Debrid: Failed to add torrent file.")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail="Real-Debrid: Failed to add torrent file."
|
status_code=451, detail="Real-Debrid: Torrent banned or unavailable for legal reasons."
|
||||||
)
|
)
|
||||||
|
|
||||||
torrent_id = upload_response["id"]
|
torrent_id = upload_response["id"]
|
||||||
|
|
||||||
logger.info(f"Real-Debrid: New torrent added with ID: {torrent_id}")
|
logger.info(f"Real-Debrid: New torrent added with ID: {torrent_id}")
|
||||||
return self.get_torrent_info(torrent_id)
|
return await self.get_torrent_info(torrent_id)
|
||||||
|
|
||||||
def add_magnet_or_torrent_and_select(self, query, ip=None):
|
async def add_magnet_or_torrent_and_select(self, query, ip=None):
|
||||||
magnet = query['magnet']
|
magnet = query['magnet']
|
||||||
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
|
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
|
||||||
stream_type = query['type']
|
stream_type = query['type']
|
||||||
|
|
@ -274,7 +290,7 @@ class RealDebrid(BaseDebrid):
|
||||||
season = query["season"]
|
season = query["season"]
|
||||||
episode = query["episode"]
|
episode = query["episode"]
|
||||||
|
|
||||||
torrent_info = self.add_magnet_or_torrent(magnet, torrent_download, ip)
|
torrent_info = await self.add_magnet_or_torrent(magnet, torrent_download, ip)
|
||||||
if not torrent_info or "files" not in torrent_info:
|
if not torrent_info or "files" not in torrent_info:
|
||||||
logger.error("Real-Debrid: Failed to add or find torrent.")
|
logger.error("Real-Debrid: Failed to add or find torrent.")
|
||||||
return None
|
return None
|
||||||
|
|
@ -283,17 +299,17 @@ class RealDebrid(BaseDebrid):
|
||||||
|
|
||||||
if is_season_pack:
|
if is_season_pack:
|
||||||
logger.info("Real-Debrid: Processing season pack")
|
logger.info("Real-Debrid: Processing season pack")
|
||||||
self._process_season_pack(torrent_info)
|
await self._process_season_pack(torrent_info)
|
||||||
else:
|
else:
|
||||||
logger.info("Real-Debrid: Selecting specific file")
|
logger.info("Real-Debrid: Selecting specific file")
|
||||||
self._select_file(
|
await self._select_file(
|
||||||
torrent_info, stream_type, file_index, season, episode
|
torrent_info, stream_type, file_index, season, episode
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Real-Debrid: Added magnet or torrent to download service: {magnet[:50]}")
|
logger.info(f"Real-Debrid: Added magnet or torrent to download service: {magnet[:50]}")
|
||||||
return torrent_info['id']
|
return torrent_info['id']
|
||||||
|
|
||||||
def _process_season_pack(self, torrent_info):
|
async def _process_season_pack(self, torrent_info):
|
||||||
logger.info("Real-Debrid: Processing season pack files")
|
logger.info("Real-Debrid: Processing season pack files")
|
||||||
video_file_indexes = [
|
video_file_indexes = [
|
||||||
str(file["id"])
|
str(file["id"])
|
||||||
|
|
@ -302,26 +318,26 @@ class RealDebrid(BaseDebrid):
|
||||||
]
|
]
|
||||||
|
|
||||||
if video_file_indexes:
|
if video_file_indexes:
|
||||||
self.select_files(torrent_info["id"], ",".join(video_file_indexes))
|
await self.select_files(torrent_info["id"], ",".join(video_file_indexes))
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Real-Debrid: Selected {len(video_file_indexes)} video files from season pack"
|
f"Real-Debrid: Selected {len(video_file_indexes)} video files from season pack"
|
||||||
)
|
)
|
||||||
time.sleep(10)
|
await asyncio.sleep(10)
|
||||||
else:
|
else:
|
||||||
logger.warning("Real-Debrid: No video files found in the season pack")
|
logger.warning("Real-Debrid: No video files found in the season pack")
|
||||||
|
|
||||||
def _select_file(self, torrent_info, stream_type, file_index, season, episode):
|
async def _select_file(self, torrent_info, stream_type, file_index, season, episode):
|
||||||
torrent_id = torrent_info["id"]
|
torrent_id = torrent_info["id"]
|
||||||
if file_index is not None:
|
if file_index is not None:
|
||||||
logger.info(f"Real-Debrid: Selecting file_index: {file_index}")
|
logger.info(f"Real-Debrid: Selecting file_index: {file_index}")
|
||||||
self.select_files(torrent_id, file_index)
|
await self.select_files(torrent_id, file_index)
|
||||||
return
|
return
|
||||||
|
|
||||||
files = torrent_info["files"]
|
files = torrent_info["files"]
|
||||||
if stream_type == "movie":
|
if stream_type == "movie":
|
||||||
largest_file_id = max(files, key=lambda x: x["bytes"])["id"]
|
largest_file_id = max(files, key=lambda x: x["bytes"])["id"]
|
||||||
logger.info(f"Real-Debrid: Selecting largest file_index: {largest_file_id}")
|
logger.info(f"Real-Debrid: Selecting largest file_index: {largest_file_id}")
|
||||||
self.select_files(torrent_id, largest_file_id)
|
await self.select_files(torrent_id, largest_file_id)
|
||||||
elif stream_type == "series":
|
elif stream_type == "series":
|
||||||
matching_files = [
|
matching_files = [
|
||||||
file
|
file
|
||||||
|
|
@ -333,17 +349,17 @@ class RealDebrid(BaseDebrid):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Real-Debrid: Selecting largest matching file_index: {largest_file_id}"
|
f"Real-Debrid: Selecting largest matching file_index: {largest_file_id}"
|
||||||
)
|
)
|
||||||
self.select_files(torrent_id, largest_file_id)
|
await self.select_files(torrent_id, largest_file_id)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Real-Debrid: No matching files found for the specified episode"
|
"Real-Debrid: No matching files found for the specified episode"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_appropriate_link(self, torrent_info, links, file_index, season, episode):
|
async def _find_appropriate_link(self, torrent_info, links, file_index, season, episode):
|
||||||
# Refresh torrent info to get the latest selected files
|
# Refresh torrent info to get the latest selected files
|
||||||
torrent_info = self.get_torrent_info(torrent_info["id"])
|
torrent_info = await self.get_torrent_info(torrent_info["id"])
|
||||||
selected_files = [file for file in torrent_info["files"] if file["selected"] == 1]
|
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)}")
|
logger.info(f"Real-Debrid: Finding appropriate link. Selected files: {len(selected_files)}, Available links: {len(links)}")
|
||||||
|
|
||||||
if not selected_files:
|
if not selected_files:
|
||||||
|
|
|
||||||
35
stream_fusion/utils/debrid/status_video.py
Normal file
35
stream_fusion/utils/debrid/status_video.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from fastapi import Response
|
||||||
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
|
||||||
|
_BASE = "https://raw.githubusercontent.com/Telkaoss/stream-fusion/refs/heads/master/stream_fusion/static/videos"
|
||||||
|
|
||||||
|
_SLOTS_FULL_URL = f"{_BASE}/slots_full.mp4"
|
||||||
|
_ERROR_URL = f"{_BASE}/error.mp4"
|
||||||
|
|
||||||
|
_TORBOX_RATE_LIMIT_URL = f"{_BASE}/torbox_rate_limit.mp4"
|
||||||
|
|
||||||
|
_STATUS_VIDEO_URLS: dict[str, str] = {
|
||||||
|
"DIFF_ISSUE": _SLOTS_FULL_URL,
|
||||||
|
"STORE_LIMIT_EXCEEDED": _SLOTS_FULL_URL,
|
||||||
|
"TORBOX_RATE_LIMIT": _TORBOX_RATE_LIMIT_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
_DEFAULT_URL = _ERROR_URL
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(key: str) -> str:
|
||||||
|
return key.upper().replace("-", "_").replace(" ", "_") if key else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_status_video_url(status_keys: list, default_key: str = "UNKNOWN") -> str:
|
||||||
|
for key in status_keys:
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
url = _STATUS_VIDEO_URLS.get(_normalize(key))
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
return _STATUS_VIDEO_URLS.get(_normalize(default_key), _DEFAULT_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_video_response(status_keys: list, default_key: str = "UNKNOWN") -> Response:
|
||||||
|
return RedirectResponse(url=get_status_video_url(status_keys, default_key), status_code=302)
|
||||||
557
stream_fusion/utils/debrid/stremthru.py
Normal file
557
stream_fusion/utils/debrid/stremthru.py
Normal file
|
|
@ -0,0 +1,557 @@
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import json as json_lib
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
|
||||||
|
from stream_fusion.utils.debrid.debrid_exceptions import DebridError
|
||||||
|
from stream_fusion.settings import settings
|
||||||
|
from stream_fusion.utils.general import season_episode_in_filename, smart_episode_fallback, is_video_file
|
||||||
|
|
||||||
|
|
||||||
|
class StremThru(BaseDebrid):
|
||||||
|
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||||
|
super().__init__(config, session)
|
||||||
|
self.config = config
|
||||||
|
self.stremthru_url = settings.stremthru_url or "https://stremthru.13377001.xyz"
|
||||||
|
self.base_url = f"{self.stremthru_url}/v0/store"
|
||||||
|
self.store_name = None
|
||||||
|
self.token = None
|
||||||
|
self._headers = {}
|
||||||
|
|
||||||
|
if not self.store_name:
|
||||||
|
self.auto_detect_store()
|
||||||
|
|
||||||
|
def auto_detect_store(self):
|
||||||
|
"""Tente de détecter automatiquement le debrideur à utiliser en fonction des tokens disponibles"""
|
||||||
|
priority_order = [
|
||||||
|
("realdebrid", "RDToken"),
|
||||||
|
("premiumize", "PMToken"),
|
||||||
|
("torbox", "TBToken"),
|
||||||
|
("alldebrid", "ADToken"),
|
||||||
|
("debridlink", "DLToken"),
|
||||||
|
("easydebrid", "EDToken"),
|
||||||
|
("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 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_underlying_debrid_code(store_name=None):
|
||||||
|
"""Retourne le code du service de debrid sous-jacent (RD, AD, TB, PM, etc.)"""
|
||||||
|
debrid_codes = {
|
||||||
|
"realdebrid": "RD",
|
||||||
|
"alldebrid": "AD",
|
||||||
|
"torbox": "TB",
|
||||||
|
"premiumize": "PM",
|
||||||
|
"offcloud": "OC",
|
||||||
|
"debridlink": "DL",
|
||||||
|
"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:
|
||||||
|
parts = token.split(":", 1)
|
||||||
|
return parts[0], parts[1]
|
||||||
|
return token, ""
|
||||||
|
|
||||||
|
async def check_premium(self, ip=None):
|
||||||
|
"""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
|
||||||
|
)
|
||||||
|
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):
|
||||||
|
"""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]
|
||||||
|
magnets = []
|
||||||
|
|
||||||
|
for hash_or_magnet in chunk:
|
||||||
|
if not hash_or_magnet.startswith('magnet:'):
|
||||||
|
clean_hash = hash_or_magnet.lower()
|
||||||
|
if len(clean_hash) > 40:
|
||||||
|
clean_hash = clean_hash[:40]
|
||||||
|
magnet_url = f"magnet:?xt=urn:btih:{clean_hash}"
|
||||||
|
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}")
|
||||||
|
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"""
|
||||||
|
try:
|
||||||
|
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}")
|
||||||
|
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
|
||||||
|
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"""
|
||||||
|
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
|
||||||
|
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 None
|
||||||
|
|
||||||
|
async def get_stream_link(self, query, config=None, ip=None):
|
||||||
|
"""Génère un lien de streaming à partir d'une requête"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"StremThru: Génération d'un lien de streaming pour {query}")
|
||||||
|
|
||||||
|
if not self.store_name:
|
||||||
|
self.auto_detect_store()
|
||||||
|
|
||||||
|
if not self.store_name:
|
||||||
|
logger.error("StremThru: Aucun debrideur configuré pour StremThru")
|
||||||
|
return None
|
||||||
|
|
||||||
|
stream_type = query.get('type')
|
||||||
|
if not stream_type:
|
||||||
|
logger.error("StremThru: Le type de média n'est pas défini dans la requête")
|
||||||
|
return None
|
||||||
|
|
||||||
|
season = query.get("season")
|
||||||
|
episode = query.get("episode")
|
||||||
|
|
||||||
|
magnet_url = query.get("magnet")
|
||||||
|
info_hash = query.get("infoHash")
|
||||||
|
file_idx = query.get("file_index", query.get("fileIdx", -1))
|
||||||
|
|
||||||
|
if magnet_url and not info_hash:
|
||||||
|
import re
|
||||||
|
hash_match = re.search(r'btih:([a-fA-F0-9]+)', magnet_url)
|
||||||
|
if hash_match:
|
||||||
|
info_hash = hash_match.group(1).lower()
|
||||||
|
logger.debug(f"StremThru: Hash extrait du magnet: {info_hash}")
|
||||||
|
|
||||||
|
if not info_hash:
|
||||||
|
logger.error("StremThru: Aucun hash trouvé dans la requête")
|
||||||
|
return None
|
||||||
|
|
||||||
|
service = query.get("service")
|
||||||
|
if service and service != "ST":
|
||||||
|
logger.debug(f"StremThru: Utilisation du service {service} spécifié dans la requête")
|
||||||
|
|
||||||
|
magnet = magnet_url or f"magnet:?xt=urn:btih:{info_hash}"
|
||||||
|
|
||||||
|
|
||||||
|
# Check cache first (no rate limit) to avoid createtorrent for cached torrents
|
||||||
|
magnet_data = None
|
||||||
|
cached_files = await self.get_availability_bulk([info_hash], ip)
|
||||||
|
if cached_files:
|
||||||
|
files = cached_files[0].get("files", [])
|
||||||
|
if files and any(f.get("link") for f in files):
|
||||||
|
logger.info(f"StremThru: Torrent {info_hash} en cache, bypass createtorrent")
|
||||||
|
magnet_data = {"files": files, "id": info_hash, "status": "cached"}
|
||||||
|
|
||||||
|
if not magnet_data:
|
||||||
|
logger.debug(f"StremThru: Ajout du magnet {magnet} via le store {self.store_name}")
|
||||||
|
magnet_info = await self.add_magnet(magnet, ip)
|
||||||
|
|
||||||
|
if not magnet_info:
|
||||||
|
logger.error(f"StremThru: Impossible d'ajouter le magnet {info_hash}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
magnet_data = magnet_info
|
||||||
|
|
||||||
|
if not magnet_data or "files" not in magnet_data:
|
||||||
|
magnet_id = magnet_info.get("id")
|
||||||
|
if magnet_id:
|
||||||
|
logger.debug(f"StremThru: Récupération des informations du magnet {magnet_id}")
|
||||||
|
magnet_data = await self.get_magnet_info(magnet_info, ip)
|
||||||
|
|
||||||
|
if not magnet_data:
|
||||||
|
logger.error(f"StremThru: Impossible de récupérer les informations du magnet")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "files" not in magnet_data:
|
||||||
|
logger.error(f"StremThru: Aucun fichier dans le magnet {magnet_data.get('id', 'inconnu')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_file = None
|
||||||
|
|
||||||
|
# Pour les séries, chercher d'abord par nom
|
||||||
|
if stream_type == "series" and season and episode:
|
||||||
|
try:
|
||||||
|
numeric_season = int(season.replace("S", ""))
|
||||||
|
numeric_episode = int(episode.replace("E", ""))
|
||||||
|
|
||||||
|
for file in magnet_data["files"]:
|
||||||
|
file_name = file.get("name", "").lower()
|
||||||
|
|
||||||
|
if not any(ext in file_name for ext in [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if season_episode_in_filename(file_name, numeric_season, numeric_episode):
|
||||||
|
target_file = file
|
||||||
|
logger.info(f"StremThru: Fichier trouvé par NOM: {file_name} (index: {file.get('index')})")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"StremThru: Erreur lors de la recherche par nom: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
if not target_file and file_idx is not None:
|
||||||
|
target_file = next((f for f in magnet_data["files"] if f.get("index") == file_idx), None)
|
||||||
|
if target_file:
|
||||||
|
logger.info(f"StremThru: Fichier trouvé par INDEX {file_idx}: {target_file.get('name')}")
|
||||||
|
|
||||||
|
if stream_type == "movie":
|
||||||
|
file_name = target_file.get("name", "").lower()
|
||||||
|
if any(ext in file_name for ext in [".nfo", ".txt", ".jpg", ".png", ".srt", ".sub"]):
|
||||||
|
logger.warning(f"StremThru: Le fichier à l'index {file_idx} n'est pas une vidéo: {target_file.get('name')}")
|
||||||
|
target_file = None
|
||||||
|
|
||||||
|
# Try smart fallback for series before falling back to largest file
|
||||||
|
if not target_file and stream_type == "series" and season and episode:
|
||||||
|
try:
|
||||||
|
video_files = [f for f in magnet_data["files"] if is_video_file(f.get("name", ""))]
|
||||||
|
|
||||||
|
if video_files:
|
||||||
|
numeric_season = int(season.replace("S", ""))
|
||||||
|
numeric_episode = int(episode.replace("E", ""))
|
||||||
|
fallback_file = smart_episode_fallback(video_files, numeric_season, numeric_episode)
|
||||||
|
|
||||||
|
if fallback_file:
|
||||||
|
target_file = fallback_file
|
||||||
|
logger.info(f"StremThru: Fichier trouvé par FALLBACK INTELLIGENT: {target_file.get('name')} (index: {target_file.get('index')})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"StremThru: Erreur lors du fallback intelligent: {str(e)}")
|
||||||
|
|
||||||
|
if not target_file:
|
||||||
|
logger.debug(f"StremThru: Aucun fichier trouvé par nom ou index, recherche du plus gros fichier")
|
||||||
|
video_files = []
|
||||||
|
|
||||||
|
for file in magnet_data["files"]:
|
||||||
|
file_name = file.get("name", "").lower()
|
||||||
|
|
||||||
|
if any(ext in file_name for ext in [".nfo", ".txt", ".jpg", ".png", ".srt", ".sub"]):
|
||||||
|
logger.debug(f"StremThru: Ignoré le fichier non-vidéo: {file_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(ext in file_name for ext in [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"]):
|
||||||
|
video_files.append(file)
|
||||||
|
|
||||||
|
if video_files:
|
||||||
|
target_file = sorted(video_files, key=lambda x: x.get("size", 0), reverse=True)[0]
|
||||||
|
logger.info(f"StremThru: Sélection du plus gros fichier vidéo: {target_file.get('name')} (index: {target_file.get('index')})")
|
||||||
|
else:
|
||||||
|
target_file = magnet_data["files"][0] if magnet_data["files"] else None
|
||||||
|
if target_file:
|
||||||
|
logger.warning(f"StremThru: Aucun fichier vidéo trouvé, utilisation du premier fichier: {target_file.get('name')} (index: {target_file.get('index')})")
|
||||||
|
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]}...")
|
||||||
|
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):
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
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
|
from itertools import islice
|
||||||
import uuid
|
import uuid
|
||||||
import tenacity
|
import asyncio
|
||||||
from urllib.parse import unquote
|
import aiohttp
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
|
from stream_fusion.utils.debrid.base_debrid import BaseDebrid
|
||||||
|
|
@ -9,9 +9,10 @@ from stream_fusion.utils.general import get_info_hash_from_magnet, season_episod
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
class Torbox(BaseDebrid):
|
class Torbox(BaseDebrid):
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: aiohttp.ClientSession = None):
|
||||||
super().__init__(config)
|
super().__init__(config, session)
|
||||||
self.base_url = f"{settings.tb_base_url}/{settings.tb_api_version}/api"
|
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"]
|
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}")
|
logger.info(f"Torbox: Initialized with base URL: {self.base_url}")
|
||||||
|
|
@ -26,27 +27,27 @@ class Torbox(BaseDebrid):
|
||||||
return {"Authorization": f"Bearer {settings.tb_token}"}
|
return {"Authorization": f"Bearer {settings.tb_token}"}
|
||||||
else:
|
else:
|
||||||
logger.warning("TorBox: Unique account enabled, but no token provided. Please provide a token in the env.")
|
logger.warning("TorBox: Unique account enabled, but no token provided. Please provide a token in the env.")
|
||||||
raise HTTPException(status_code=500, detail="AllDebrid token is not provided.")
|
raise HTTPException(status_code=500, detail="TorBox token is not provided.")
|
||||||
else:
|
else:
|
||||||
return {"Authorization": f"Bearer {self.config["TBToken"]}"}
|
return {"Authorization": f"Bearer {self.config['TBToken']}"}
|
||||||
|
|
||||||
def add_magnet(self, magnet, ip=None, privacy="private"):
|
async def add_magnet(self, magnet, ip=None, privacy="private"):
|
||||||
logger.info(f"Torbox: Adding magnet: {magnet[:50]}...")
|
logger.info(f"Torbox: Adding magnet: {magnet[:50]}...")
|
||||||
url = f"{self.base_url}/torrents/createtorrent"
|
url = f"{self.base_url}/torrents/createtorrent"
|
||||||
seed = 2 if privacy == "private" else 1
|
seed = 3
|
||||||
data = {
|
data = {
|
||||||
"magnet": magnet,
|
"magnet": magnet,
|
||||||
"seed": seed,
|
"seed": seed,
|
||||||
"allow_zip": "false"
|
"allow_zip": "false"
|
||||||
}
|
}
|
||||||
response = self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data, retry_on_429=False)
|
||||||
logger.info(f"Torbox: Add magnet response: {response}")
|
logger.info(f"Torbox: Add magnet response: {response}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def add_torrent(self, torrent_file, privacy="private"):
|
async def add_torrent(self, torrent_file, privacy="private"):
|
||||||
logger.info("Torbox: Adding torrent file")
|
logger.info("Torbox: Adding torrent file")
|
||||||
url = f"{self.base_url}/torrents/createtorrent"
|
url = f"{self.base_url}/torrents/createtorrent"
|
||||||
seed = 2 if privacy == "private" else 1
|
seed = 3
|
||||||
data = {
|
data = {
|
||||||
"seed": seed,
|
"seed": seed,
|
||||||
"allow_zip": "false"
|
"allow_zip": "false"
|
||||||
|
|
@ -54,87 +55,105 @@ class Torbox(BaseDebrid):
|
||||||
files = {
|
files = {
|
||||||
"file": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')
|
"file": (str(uuid.uuid4()) + ".torrent", torrent_file, 'application/x-bittorrent')
|
||||||
}
|
}
|
||||||
response = self.json_response(url, method='post', headers=self.get_headers(), data=data, files=files)
|
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data, files=files, retry_on_429=False)
|
||||||
logger.info(f"Torbox: Add torrent file response: {response}")
|
logger.info(f"Torbox: Add torrent file response: {response}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def get_torrent_info(self, torrent_id):
|
async def get_torrent_info(self, torrent_id):
|
||||||
logger.info(f"Torbox: Getting info for torrent ID: {torrent_id}")
|
logger.info(f"Torbox: Getting info for torrent ID: {torrent_id}")
|
||||||
url = f"{self.base_url}/torrents/mylist?bypass_cache=true&id={torrent_id}"
|
url = f"{self.base_url}/torrents/mylist?bypass_cache=true&id={torrent_id}"
|
||||||
response = self.json_response(url, headers=self.get_headers())
|
response = await self.json_response(url, headers=self.get_headers())
|
||||||
logger.debug(f"Torbox: Torrent info response: {response}")
|
logger.debug(f"Torbox: Torrent info response: {response}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def control_torrent(self, torrent_id, operation):
|
async def control_torrent(self, torrent_id, operation):
|
||||||
logger.info(f"Torbox: Controlling torrent ID: {torrent_id}, operation: {operation}")
|
logger.info(f"Torbox: Controlling torrent ID: {torrent_id}, operation: {operation}")
|
||||||
url = f"{self.base_url}/torrents/controltorrent"
|
url = f"{self.base_url}/torrents/controltorrent"
|
||||||
data = {
|
data = {
|
||||||
"torrent_id": torrent_id,
|
"torrent_id": torrent_id,
|
||||||
"operation": operation
|
"operation": operation
|
||||||
}
|
}
|
||||||
response = self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
response = await self.json_response(url, method='post', headers=self.get_headers(), data=data)
|
||||||
logger.info(f"Torbox: Control torrent response: {response}")
|
logger.info(f"Torbox: Control torrent response: {response}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@tenacity.retry(
|
async def request_download_link(self, torrent_id, file_id=None, zip_link=False):
|
||||||
stop=tenacity.stop_after_attempt(3),
|
"""Request download link with retry logic"""
|
||||||
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}")
|
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()}"
|
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}")
|
logger.info(f"Torbox: Requesting URL: {url}")
|
||||||
response = self.json_response(url, headers=self.get_headers())
|
|
||||||
logger.info(f"Torbox: Request download link response: {response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
def get_stream_link(self, query, config, ip=None):
|
max_attempts = 3
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
response = await self.json_response(url, headers=self.get_headers())
|
||||||
|
logger.info(f"Torbox: Request download link response: {response}")
|
||||||
|
return response
|
||||||
|
except (HTTPException, asyncio.TimeoutError) as e:
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
logger.warning(f"Torbox: Retry {attempt + 1}/{max_attempts} for download link request")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_stream_link(self, query, config=None, ip=None):
|
||||||
magnet = query['magnet']
|
magnet = query['magnet']
|
||||||
stream_type = query['type']
|
stream_type = query['type']
|
||||||
file_index = int(query['file_index']) if query['file_index'] is not None else None
|
file_index = int(query['file_index']) if query['file_index'] is not None else None
|
||||||
season = query['season']
|
season = query['season']
|
||||||
episode = query['episode']
|
episode = query['episode']
|
||||||
torrent_download = unquote(query["torrent_download"]) if query["torrent_download"] is not None else None
|
torrent_download = query["torrent_download"]
|
||||||
|
if torrent_download:
|
||||||
|
from urllib.parse import unquote
|
||||||
|
torrent_download = unquote(torrent_download)
|
||||||
|
|
||||||
info_hash = get_info_hash_from_magnet(magnet)
|
info_hash = get_info_hash_from_magnet(magnet)
|
||||||
logger.info(f"Torbox: Getting stream link for {stream_type} with hash: {info_hash}")
|
logger.info(f"Torbox: Getting stream link for {stream_type} with hash: {info_hash}")
|
||||||
|
|
||||||
# Check if the torrent is already added
|
# Check if the torrent is already added
|
||||||
existing_torrent = self._find_existing_torrent(info_hash)
|
existing_torrent = await self._find_existing_torrent(info_hash)
|
||||||
|
|
||||||
if existing_torrent:
|
if existing_torrent:
|
||||||
logger.info(f"Torbox: Found existing torrent with ID: {existing_torrent['id']}")
|
logger.info(f"Torbox: Found existing torrent with ID: {existing_torrent['id']}")
|
||||||
torrent_info = existing_torrent
|
torrent_id = existing_torrent["id"]
|
||||||
if not torrent_info or "id" not in torrent_info:
|
# Get full torrent info with files
|
||||||
logger.error("Torbox: Failed to add or find torrent.")
|
torrent_response = await self.get_torrent_info(torrent_id)
|
||||||
|
if not torrent_response or "data" not in torrent_response:
|
||||||
|
logger.error("Torbox: Failed to get torrent info.")
|
||||||
return None
|
return None
|
||||||
torrent_id = torrent_info["id"]
|
torrent_info = torrent_response["data"]
|
||||||
else:
|
else:
|
||||||
# Add the magnet or torrent file
|
# Add the magnet or torrent file
|
||||||
torrent_info = self.add_magnet_or_torrent(magnet, torrent_download)
|
add_response = await self.add_magnet_or_torrent(magnet, torrent_download)
|
||||||
if not torrent_info or "torrent_id" not in torrent_info:
|
if not add_response or "torrent_id" not in add_response:
|
||||||
logger.error("Torbox: Failed to add or find torrent.")
|
logger.error("Torbox: Failed to add or find torrent.")
|
||||||
return None
|
return None
|
||||||
torrent_id = torrent_info["torrent_id"]
|
torrent_id = add_response["torrent_id"]
|
||||||
|
# Get full torrent info with files
|
||||||
|
torrent_response = await self.get_torrent_info(torrent_id)
|
||||||
|
if not torrent_response or "data" not in torrent_response:
|
||||||
|
logger.error("Torbox: Failed to get torrent info.")
|
||||||
|
return None
|
||||||
|
torrent_info = torrent_response["data"]
|
||||||
|
|
||||||
logger.info(f"Torbox: Working with torrent ID: {torrent_id}")
|
logger.info(f"Torbox: Working with torrent ID: {torrent_id}")
|
||||||
|
|
||||||
# Wait for the torrent to be ready
|
# Wait for the torrent to be ready
|
||||||
if not self._wait_for_torrent_completion(torrent_id):
|
if not await self._wait_for_torrent_completion(torrent_id):
|
||||||
logger.warning("Torbox: Torrent not ready, caching in progress.")
|
logger.warning("Torbox: Torrent not ready, caching in progress.")
|
||||||
return settings.no_cache_video_url
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
# Select the appropriate file
|
# Select the appropriate file
|
||||||
file_id = self._select_file(torrent_info, stream_type, file_index, season, episode)
|
file_id = self._select_file(torrent_info, stream_type, file_index, season, episode)
|
||||||
|
|
||||||
if file_id == None:
|
if file_id is None:
|
||||||
logger.error("Torbox: No matching file found.")
|
logger.error("Torbox: No matching file found.")
|
||||||
return settings.no_cache_video_url
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
# Request the download link
|
# Request the download link
|
||||||
download_link_response = self.request_download_link(torrent_id, file_id)
|
download_link_response = await self.request_download_link(torrent_id, file_id)
|
||||||
|
|
||||||
if not download_link_response or "data" not in download_link_response:
|
if not download_link_response or "data" not in download_link_response:
|
||||||
logger.error("Torbox: Failed to get download link.")
|
logger.error("Torbox: Failed to get download link.")
|
||||||
return settings.no_cache_video_url
|
return settings.no_cache_video_url
|
||||||
|
|
@ -142,24 +161,23 @@ class Torbox(BaseDebrid):
|
||||||
logger.info(f"Torbox: Got download link: {download_link_response['data']}")
|
logger.info(f"Torbox: Got download link: {download_link_response['data']}")
|
||||||
return download_link_response['data']
|
return download_link_response['data']
|
||||||
|
|
||||||
def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
async def get_availability_bulk(self, hashes_or_magnets, ip=None):
|
||||||
logger.info(f"Torbox: Checking availability for {len(hashes_or_magnets)} hashes/magnets")
|
logger.info(f"Torbox: Checking availability for {len(hashes_or_magnets)} hashes/magnets")
|
||||||
|
|
||||||
all_results = []
|
all_results = []
|
||||||
|
|
||||||
for i in range(0, len(hashes_or_magnets), 50):
|
for i in range(0, len(hashes_or_magnets), 50):
|
||||||
batch = list(islice(hashes_or_magnets, i, i + 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})")
|
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"
|
url = f"{self.base_url}/torrents/checkcached?hash={','.join(batch)}&format=list&list_files=true"
|
||||||
logger.trace(f"Torbox: Requesting URL: {url}")
|
response = await self.json_response(url, headers=self.get_headers())
|
||||||
response = self.json_response(url, headers=self.get_headers())
|
|
||||||
|
|
||||||
if response and response.get("success") and response["data"]:
|
if response and response.get("success") and response["data"]:
|
||||||
all_results.extend(response["data"])
|
all_results.extend(response["data"])
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Torbox: No cached avaibility for batch {i//50 + 1}")
|
logger.debug(f"Torbox: No cached availability for batch {i//50 + 1}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f"Torbox: Availability check completed for all {len(hashes_or_magnets)} hashes/magnets")
|
logger.info(f"Torbox: Availability check completed for all {len(hashes_or_magnets)} hashes/magnets")
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
@ -167,9 +185,9 @@ class Torbox(BaseDebrid):
|
||||||
"data": all_results
|
"data": all_results
|
||||||
}
|
}
|
||||||
|
|
||||||
def _find_existing_torrent(self, info_hash):
|
async def _find_existing_torrent(self, info_hash):
|
||||||
logger.info(f"Torbox: Searching for existing torrent with hash: {info_hash}")
|
logger.info(f"Torbox: Searching for existing torrent with hash: {info_hash}")
|
||||||
torrents = self.json_response(f"{self.base_url}/torrents/mylist", headers=self.get_headers())
|
torrents = await self.json_response(f"{self.base_url}/torrents/mylist", headers=self.get_headers())
|
||||||
if torrents and "data" in torrents:
|
if torrents and "data" in torrents:
|
||||||
for torrent in torrents["data"]:
|
for torrent in torrents["data"]:
|
||||||
if torrent["hash"].lower() == info_hash.lower():
|
if torrent["hash"].lower() == info_hash.lower():
|
||||||
|
|
@ -178,14 +196,10 @@ class Torbox(BaseDebrid):
|
||||||
logger.info("Torbox: No existing torrent found")
|
logger.info("Torbox: No existing torrent found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None, privacy="private"):
|
async def add_magnet_or_torrent(self, magnet, torrent_download=None, ip=None, privacy="private"):
|
||||||
if torrent_download is None:
|
# Always use magnet: TorBox ignores seed=3 with .torrent file uploads
|
||||||
logger.info("Torbox: Adding magnet")
|
logger.info("Torbox: Adding magnet (ignoring .torrent to preserve seed settings)")
|
||||||
response = self.add_magnet(magnet, ip, privacy)
|
response = await 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}")
|
logger.info(f"Torbox: Add torrent response: {response}")
|
||||||
|
|
||||||
|
|
@ -195,17 +209,18 @@ class Torbox(BaseDebrid):
|
||||||
|
|
||||||
return response["data"]
|
return response["data"]
|
||||||
|
|
||||||
def _wait_for_torrent_completion(self, torrent_id, timeout=60, interval=10):
|
async def _wait_for_torrent_completion(self, torrent_id, timeout=60, interval=10):
|
||||||
logger.info(f"Torbox: Waiting for torrent completion, ID: {torrent_id}")
|
logger.info(f"Torbox: Waiting for torrent completion, ID: {torrent_id}")
|
||||||
def check_status():
|
|
||||||
torrent_info = self.get_torrent_info(torrent_id)
|
async def check_status():
|
||||||
|
torrent_info = await self.get_torrent_info(torrent_id)
|
||||||
if torrent_info and "data" in torrent_info:
|
if torrent_info and "data" in torrent_info:
|
||||||
files = torrent_info["data"].get("files", [])
|
files = torrent_info["data"].get("files", [])
|
||||||
logger.info(f"Torbox: Current torrent status: {torrent_info['data']['download_state']}")
|
logger.info(f"Torbox: Current torrent status: {torrent_info['data']['download_state']}")
|
||||||
return True if len(files) > 0 else False
|
return True if len(files) > 0 else False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
result = self.wait_for_ready_status(check_status, timeout, interval)
|
result = await self.wait_for_ready_status(check_status, timeout, interval)
|
||||||
if result:
|
if result:
|
||||||
logger.info("Torbox: Torrent is ready")
|
logger.info("Torbox: Torrent is ready")
|
||||||
else:
|
else:
|
||||||
|
|
@ -215,7 +230,7 @@ class Torbox(BaseDebrid):
|
||||||
def _select_file(self, torrent_info, stream_type, file_index, season, episode):
|
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}")
|
logger.info(f"Torbox: Selecting file for {stream_type}, file_index: {file_index}, season: {season}, episode: {episode}")
|
||||||
files = torrent_info.get("files", [])
|
files = torrent_info.get("files", [])
|
||||||
|
|
||||||
if stream_type == "movie":
|
if stream_type == "movie":
|
||||||
if file_index is not None:
|
if file_index is not None:
|
||||||
logger.info(f"Torbox: Selected file index {file_index} for movie")
|
logger.info(f"Torbox: Selected file index {file_index} for movie")
|
||||||
|
|
@ -223,23 +238,68 @@ class Torbox(BaseDebrid):
|
||||||
largest_file = max(files, key=lambda x: x["size"])
|
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")
|
logger.info(f"Torbox: Selected largest file (ID: {largest_file['id']}, Size: {largest_file['size']}) for movie")
|
||||||
return largest_file["id"]
|
return largest_file["id"]
|
||||||
|
|
||||||
elif stream_type == "series":
|
elif stream_type == "series":
|
||||||
if file_index is not None:
|
if file_index is not None:
|
||||||
logger.info(f"Torbox: Selected file index {file_index} for series")
|
logger.info(f"Torbox: Selected file index {file_index} for series")
|
||||||
return file_index
|
return file_index
|
||||||
|
|
||||||
matching_files = [
|
try:
|
||||||
file for file in files
|
numeric_season = int(season.replace("S", ""))
|
||||||
if season_episode_in_filename(file["short_name"], season, episode) and is_video_file(file["short_name"])
|
numeric_episode = int(episode.replace("E", ""))
|
||||||
]
|
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")
|
||||||
|
for i, file in enumerate(files):
|
||||||
|
logger.debug(f"Torbox: DEBUG - File {i+1}: {file['short_name']} (size: {file['size']}, is_video: {is_video_file(file['short_name'])})")
|
||||||
|
|
||||||
|
matching_files = []
|
||||||
|
for file in files:
|
||||||
|
if is_video_file(file["short_name"]):
|
||||||
|
logger.debug(f"Torbox: Checking video file: {file['short_name']}")
|
||||||
|
if season_episode_in_filename(file["short_name"], numeric_season, numeric_episode):
|
||||||
|
logger.info(f"Torbox: ✓ RTN match for {file['short_name']}")
|
||||||
|
matching_files.append(file)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Torbox: ✗ No RTN match for {file['short_name']}")
|
||||||
|
|
||||||
|
logger.info(f"Torbox: {len(matching_files)} files found with RTN for S{numeric_season:02d}E{numeric_episode:02d}")
|
||||||
|
|
||||||
if matching_files:
|
if matching_files:
|
||||||
largest_matching_file = max(matching_files, key=lambda x: x["size"])
|
largest_matching_file = max(matching_files, key=lambda x: x["size"])
|
||||||
logger.info(f"Torbox: Selected largest matching file (ID: {largest_matching_file['id']}, Name: {largest_matching_file['name']}, Size: {largest_matching_file['size']}) for series")
|
logger.info(f"Torbox: Selected largest matching file (ID: {largest_matching_file['id']}, Name: {largest_matching_file['short_name']}, Size: {largest_matching_file['size']}) for series")
|
||||||
return largest_matching_file["id"]
|
return largest_matching_file["id"]
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Torbox: No matching files found for S{season}E{episode}")
|
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
|
||||||
logger.error(f"Torbox: Failed to select appropriate file for {stream_type}")
|
|
||||||
return None
|
fallback_files = [
|
||||||
|
{
|
||||||
|
"name": file["short_name"],
|
||||||
|
"size": file["size"],
|
||||||
|
"index": file["id"]
|
||||||
|
}
|
||||||
|
for file in files if is_video_file(file["short_name"])
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Torbox: Calling smart fallback with {len(fallback_files)} files")
|
||||||
|
|
||||||
|
fallback_file = smart_episode_fallback(fallback_files, numeric_season, numeric_episode)
|
||||||
|
if fallback_file:
|
||||||
|
logger.info(f"Torbox: Smart fallback selected: {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")
|
||||||
|
video_files = [f for f in files if is_video_file(f["short_name"])]
|
||||||
|
logger.debug(f"Torbox: DEBUG - Found {len(video_files)} video files in final fallback")
|
||||||
|
|
||||||
|
if len(video_files) == 1:
|
||||||
|
single_file = video_files[0]
|
||||||
|
logger.info(f"Torbox: Single video file detected, using: {single_file['short_name']} (ID: {single_file['id']})")
|
||||||
|
return single_file["id"]
|
||||||
|
else:
|
||||||
|
logger.error(f"Torbox: Smart fallback also failed for S{numeric_season:02d}E{numeric_episode:02d}")
|
||||||
|
logger.error(f"Torbox: Found {len(video_files)} video files, expected exactly 1 for single file fallback")
|
||||||
|
return None
|
||||||
|
|
|
||||||
149
stream_fusion/utils/filter/language_priority_filter.py
Normal file
149
stream_fusion/utils/filter/language_priority_filter.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import re
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
from RTN import ParsedData, title_match
|
||||||
|
from stream_fusion.constants import FRENCH_PATTERNS
|
||||||
|
from stream_fusion.utils.filter.base_filter import BaseFilter
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
||||||
|
|
||||||
|
|
||||||
|
class LanguagePriorityFilter(BaseFilter):
|
||||||
|
"""
|
||||||
|
Filtre pour trier les torrents selon une priorité de langue spécifique.
|
||||||
|
Ordre de priorité:
|
||||||
|
1. Groupe 1: VFF, VOF, VFI
|
||||||
|
2. Groupe 2: VF2, VFQ
|
||||||
|
3. Groupe 3: VOST
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
super().__init__(config)
|
||||||
|
|
||||||
|
# Vérifier si VFQ est explicitement sélectionné dans les préférences de langue
|
||||||
|
vfq_selected = 'vfq' in config.get('languages', [])
|
||||||
|
|
||||||
|
# Adapter les groupes de priorité en fonction des préférences de l'utilisateur
|
||||||
|
if vfq_selected:
|
||||||
|
# Si VFQ est sélectionné, placer VFQ et VF2 dans le groupe 1 (priorité la plus élevée)
|
||||||
|
# car VF2 est aussi du français québécois
|
||||||
|
self.language_priority_groups = {
|
||||||
|
# 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"],
|
||||||
|
# Groupe 3 (priorité basse)
|
||||||
|
3: ["VOSTFR"],
|
||||||
|
|
||||||
|
}
|
||||||
|
logger.info("VFQ sélectionné dans les préférences, VFQ et VF2 placés en priorité maximale")
|
||||||
|
else:
|
||||||
|
# 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"],
|
||||||
|
# Groupe 2 (priorité moyenne)
|
||||||
|
2: ["VF2", "VFQ", "VQ", "FRENCH"],
|
||||||
|
# Groupe 3 (priorité basse)
|
||||||
|
3: ["VOSTFR"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Créer un dictionnaire inversé pour un accès rapide à la priorité par langue
|
||||||
|
self.language_priority_map = {}
|
||||||
|
for priority, languages in self.language_priority_groups.items():
|
||||||
|
for lang in languages:
|
||||||
|
self.language_priority_map[lang] = priority
|
||||||
|
|
||||||
|
def filter(self, data: List[TorrentItem]) -> List[TorrentItem]:
|
||||||
|
"""
|
||||||
|
Trie les torrents selon la priorité de langue définie.
|
||||||
|
Utilise RTN pour l'analyse et le classement des torrents.
|
||||||
|
"""
|
||||||
|
for torrent in data:
|
||||||
|
language_priority = self._get_language_priority(torrent)
|
||||||
|
|
||||||
|
torrent.language_priority = language_priority
|
||||||
|
|
||||||
|
logger.trace(f"Torrent {torrent.raw_title} a une priorité de langue: {language_priority}")
|
||||||
|
|
||||||
|
|
||||||
|
sorted_data = sorted(data, key=lambda x: x.language_priority)
|
||||||
|
|
||||||
|
logger.info(f"Tri par langue terminé. Ordre des langues: VFF/VOF/VFI > VF2/VFQ > VOST > autres")
|
||||||
|
|
||||||
|
return sorted_data
|
||||||
|
|
||||||
|
def _get_language_priority(self, torrent: TorrentItem) -> int:
|
||||||
|
"""
|
||||||
|
Détermine la priorité de langue d'un torrent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
torrent: L'objet torrent à évaluer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Valeur de priorité (plus petit = plus prioritaire)
|
||||||
|
"""
|
||||||
|
language = self._detect_language_from_title(torrent.raw_title)
|
||||||
|
|
||||||
|
if not language:
|
||||||
|
if hasattr(torrent, 'languages') and torrent.languages:
|
||||||
|
best_priority = 999
|
||||||
|
for lang in torrent.languages:
|
||||||
|
lang_code = self._convert_language_code(lang)
|
||||||
|
if lang_code in self.language_priority_map:
|
||||||
|
priority = self.language_priority_map[lang_code]
|
||||||
|
best_priority = min(best_priority, priority)
|
||||||
|
return best_priority
|
||||||
|
return 999
|
||||||
|
|
||||||
|
return self.language_priority_map.get(language, 998)
|
||||||
|
|
||||||
|
def _detect_language_from_title(self, title: str) -> str:
|
||||||
|
"""
|
||||||
|
Détecte la langue à partir du titre du torrent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Titre du torrent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Code de langue détecté ou None
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for language, pattern in FRENCH_PATTERNS.items():
|
||||||
|
if re.search(pattern, title, re.IGNORECASE):
|
||||||
|
return language
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _convert_language_code(self, lang_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Convertit les codes de langue courts en codes correspondant à nos groupes de priorité.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lang_code: Code de langue court (ex: 'fr', 'multi')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Code de langue correspondant à nos groupes ou None
|
||||||
|
"""
|
||||||
|
lang_mapping = {
|
||||||
|
'fr': 'FRENCH',
|
||||||
|
'vff': 'VFF',
|
||||||
|
'vf': 'FRENCH',
|
||||||
|
'vostfr': 'VOSTFR',
|
||||||
|
'multi': 'VFF',
|
||||||
|
'voi': 'VOF',
|
||||||
|
'vfi': 'VFI',
|
||||||
|
'vf2': 'VF2',
|
||||||
|
'vfq': 'VFQ'
|
||||||
|
}
|
||||||
|
|
||||||
|
return lang_mapping.get(lang_code.lower(), None)
|
||||||
|
|
||||||
|
def can_filter(self):
|
||||||
|
"""
|
||||||
|
Ce filtre peut toujours être appliqué, car il s'agit d'un tri et non d'une exclusion.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from stream_fusion.utils.filter.base_filter import BaseFilter
|
from stream_fusion.utils.filter.base_filter import BaseFilter
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
#TODO: Check if this filter is still needed and RTN changes for it.
|
|
||||||
class ResultsPerQualityFilter(BaseFilter):
|
class ResultsPerQualityFilter(BaseFilter):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
|
|
@ -9,20 +8,41 @@ class ResultsPerQualityFilter(BaseFilter):
|
||||||
|
|
||||||
def filter(self, data):
|
def filter(self, data):
|
||||||
filtered_items = []
|
filtered_items = []
|
||||||
resolution_count = {}
|
resolution_groups = {}
|
||||||
|
|
||||||
for item in data:
|
for item in data:
|
||||||
resolution = getattr(item.parsed_data, 'resolution', "?.BZH.?")
|
resolution = getattr(item.parsed_data, 'resolution', "?.BZH.?")
|
||||||
|
if resolution not in resolution_groups:
|
||||||
if resolution not in resolution_count:
|
resolution_groups[resolution] = []
|
||||||
resolution_count[resolution] = 1
|
resolution_groups[resolution].append(item)
|
||||||
filtered_items.append(item)
|
|
||||||
elif resolution_count[resolution] < self.max_results_per_quality:
|
sort_method = self.config.get('sort', '')
|
||||||
resolution_count[resolution] += 1
|
logger.info(f"ResultsPerQualityFilter: Using sort method: {sort_method} (RTN will handle sorting)")
|
||||||
filtered_items.append(item)
|
|
||||||
|
if sort_method in ['sizedesc', 'sizeasc', 'qualitythensize']:
|
||||||
|
logger.info(f"ResultsPerQualityFilter: Size-based sorting detected, passing all items to RTN")
|
||||||
|
for resolution, items in resolution_groups.items():
|
||||||
|
filtered_items.extend(items)
|
||||||
|
logger.debug(f"ResultsPerQualityFilter: Passing all {len(items)} items for resolution {resolution} to RTN")
|
||||||
|
else:
|
||||||
|
for resolution, items in resolution_groups.items():
|
||||||
|
|
||||||
|
|
||||||
|
limited_items = items[:self.max_results_per_quality]
|
||||||
|
filtered_items.extend(limited_items)
|
||||||
|
|
||||||
|
if limited_items and len(limited_items) > 0:
|
||||||
|
sizes_gb = [int(item.size) / (1024*1024*1024) for item in limited_items]
|
||||||
|
logger.info(f"ResultsPerQualityFilter: For {resolution}, selected file sizes (GB): {', '.join([f'{size:.2f}' for size in sizes_gb])}")
|
||||||
|
|
||||||
|
logger.debug(f"ResultsPerQualityFilter: Kept {len(limited_items)} items for resolution {resolution}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger.debug(f"ResultsPerQualityFilter: input {len(data)}, output {len(filtered_items)}")
|
logger.debug(f"ResultsPerQualityFilter: input {len(data)}, output {len(filtered_items)}")
|
||||||
return filtered_items
|
return filtered_items
|
||||||
|
|
||||||
def can_filter(self):
|
def can_filter(self):
|
||||||
return self.max_results_per_quality > 0
|
can_apply = self.max_results_per_quality > 0
|
||||||
|
logger.debug(f"ResultsPerQualityFilter.can_filter() returned {can_apply} with max_results_per_quality={self.max_results_per_quality}")
|
||||||
|
return can_apply
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from typing import List
|
||||||
from RTN import title_match
|
from RTN import title_match
|
||||||
|
|
||||||
from stream_fusion.utils.filter.language_filter import LanguageFilter
|
from stream_fusion.utils.filter.language_filter import LanguageFilter
|
||||||
|
from stream_fusion.utils.filter.language_priority_filter import LanguagePriorityFilter
|
||||||
from stream_fusion.utils.filter.max_size_filter import MaxSizeFilter
|
from stream_fusion.utils.filter.max_size_filter import MaxSizeFilter
|
||||||
from stream_fusion.utils.filter.quality_exclusion_filter import QualityExclusionFilter
|
from stream_fusion.utils.filter.quality_exclusion_filter import QualityExclusionFilter
|
||||||
from stream_fusion.utils.filter.title_exclusion_filter import TitleExclusionFilter
|
from stream_fusion.utils.filter.title_exclusion_filter import TitleExclusionFilter
|
||||||
|
|
@ -12,26 +13,78 @@ from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
quality_order = {"2160p": 0, "1080p": 1, "720p": 2, "480p": 3}
|
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):
|
def sort_quality(item: TorrentItem):
|
||||||
|
"""Retourne (resolution_priority, is_unknown) pour le tri."""
|
||||||
logger.trace(f"Filters: Evaluating quality for item: {item.raw_title}")
|
logger.trace(f"Filters: Evaluating quality for item: {item.raw_title}")
|
||||||
if not item.parsed_data.resolution:
|
# Ensure parsed_data is valid before accessing it
|
||||||
|
if hasattr(item, '_ensure_parsed_data_valid'):
|
||||||
|
item._ensure_parsed_data_valid()
|
||||||
|
# Check if parsed_data exists and is valid
|
||||||
|
if not item.parsed_data or not hasattr(item.parsed_data, 'resolution'):
|
||||||
return float("inf"), True
|
return float("inf"), True
|
||||||
resolution = item.parsed_data.resolution
|
resolution = item.parsed_data.resolution
|
||||||
priority = quality_order.get(resolution, float("inf"))
|
priority = quality_order.get(resolution, float("inf"))
|
||||||
return priority, item.parsed_data.resolution is None
|
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):
|
def items_sort(items, config):
|
||||||
logger.info(f"Filters: Sorting items by method: {config['sort']}")
|
logger.info(f"Filters: Sorting items by method: {config['sort']}")
|
||||||
if config["sort"] == "quality":
|
if config["sort"] == "quality":
|
||||||
sorted_items = sorted(items, key=sort_quality)
|
sorted_items = sorted(items, key=lambda x: (sort_quality(x), get_indexer_priority_for_sort(x.indexer, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
|
||||||
elif config["sort"] == "sizeasc":
|
elif config["sort"] == "sizeasc":
|
||||||
sorted_items = sorted(items, key=lambda x: int(x.size))
|
sorted_items = sorted(items, key=lambda x: (int(x.size), get_indexer_priority_for_sort(x.indexer, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
|
||||||
elif config["sort"] == "sizedesc":
|
elif config["sort"] == "sizedesc":
|
||||||
sorted_items = sorted(items, key=lambda x: int(x.size), reverse=True)
|
sorted_items = sorted(items, key=lambda x: (-int(x.size), get_indexer_priority_for_sort(x.indexer, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
|
||||||
elif config["sort"] == "qualitythensize":
|
elif config["sort"] == "qualitythensize":
|
||||||
sorted_items = sorted(items, key=lambda x: (sort_quality(x), -int(x.size)))
|
sorted_items = sorted(items, key=lambda x: (sort_quality(x), -int(x.size), get_indexer_priority_for_sort(x.indexer, config), get_item_hdr_priority(x), getattr(x, "language_priority", 999), -int(x.seeders or 0)))
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Filters: Unrecognized sort method: {config['sort']}. No sorting applied."
|
f"Filters: Unrecognized sort method: {config['sort']}. No sorting applied."
|
||||||
|
|
@ -39,7 +92,7 @@ def items_sort(items, config):
|
||||||
sorted_items = items
|
sorted_items = items
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Filters: Sorting complete. Number of sorted items: {len(sorted_items)}"
|
f"Filters: Sorting complete - Quality/Size first, YggFlix priority at equal quality, seeders as tiebreaker. Number of sorted items: {len(sorted_items)}"
|
||||||
)
|
)
|
||||||
return sorted_items
|
return sorted_items
|
||||||
|
|
||||||
|
|
@ -78,6 +131,11 @@ def filter_out_non_matching_series(items, season, episode):
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in items:
|
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 len(item.parsed_data.seasons) == 0 and len(item.parsed_data.episodes) == 0:
|
||||||
if integrale_pattern.search(item.raw_title):
|
if integrale_pattern.search(item.raw_title):
|
||||||
logger.trace(
|
logger.trace(
|
||||||
|
|
@ -153,9 +211,26 @@ def remove_non_matching_title(items, titles):
|
||||||
return subset_index == len(subset_words)
|
return subset_index == len(subset_words)
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
cleaned_item_title = integrale_pattern.sub(
|
# Ensure parsed_data is valid before accessing it
|
||||||
"", item.parsed_data.parsed_title
|
if hasattr(item, '_ensure_parsed_data_valid'):
|
||||||
).strip()
|
item._ensure_parsed_data_valid()
|
||||||
|
|
||||||
|
# If parsed_data is None or invalid, use raw_title as fallback
|
||||||
|
if item.parsed_data and hasattr(item.parsed_data, 'parsed_title'):
|
||||||
|
cleaned_item_title = integrale_pattern.sub(
|
||||||
|
"", item.parsed_data.parsed_title
|
||||||
|
).strip()
|
||||||
|
else:
|
||||||
|
# Fallback to raw_title if parsed_data is invalid
|
||||||
|
cleaned_item_title = integrale_pattern.sub(
|
||||||
|
"", item.raw_title
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if item.indexer and "Yggtorrent" in item.indexer:
|
||||||
|
logger.debug(f"Filters: YggFlix item detected, accepting: {cleaned_item_title}")
|
||||||
|
filtered_items.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
for title in cleaned_titles:
|
for title in cleaned_titles:
|
||||||
logger.trace(
|
logger.trace(
|
||||||
f"Filters: Comparing item title: {cleaned_item_title} with title: {title}"
|
f"Filters: Comparing item title: {cleaned_item_title} with title: {title}"
|
||||||
|
|
@ -190,15 +265,21 @@ def remove_non_matching_title(items, titles):
|
||||||
return filtered_items
|
return filtered_items
|
||||||
|
|
||||||
|
|
||||||
def filter_items(items, media, config):
|
def filter_items(items, media, config, skip_resolution=False):
|
||||||
logger.info(f"Filters: Starting item filtering for media: {media.titles[0]}")
|
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 = {
|
filters = {
|
||||||
"languages": LanguageFilter(config),
|
"languages": LanguageFilter(config),
|
||||||
"maxSize": MaxSizeFilter(config, media.type),
|
"maxSize": MaxSizeFilter(config, media.type),
|
||||||
"exclusionKeywords": TitleExclusionFilter(config),
|
"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)}")
|
logger.info(f"Filters: Initial item count: {len(items)}")
|
||||||
|
|
||||||
|
|
@ -232,6 +313,29 @@ def filter_items(items, media, config):
|
||||||
f"Filters: Error while applying {filter_name} filter", exc_info=e
|
f"Filters: Error while applying {filter_name} filter", exc_info=e
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Filters: Applying language priority filter")
|
||||||
|
items = language_priority_filter(items)
|
||||||
|
logger.success(f"Filters: Items sorted by language priority")
|
||||||
|
|
||||||
|
language_groups = {}
|
||||||
|
for item in items:
|
||||||
|
priority = getattr(item, 'language_priority', 999)
|
||||||
|
if priority not in language_groups:
|
||||||
|
language_groups[priority] = []
|
||||||
|
language_groups[priority].append(item)
|
||||||
|
|
||||||
|
sorted_items = []
|
||||||
|
for priority in sorted(language_groups.keys()):
|
||||||
|
group_items = language_groups[priority]
|
||||||
|
sorted_group = items_sort(group_items, config)
|
||||||
|
sorted_items.extend(sorted_group)
|
||||||
|
|
||||||
|
items = sorted_items
|
||||||
|
logger.success(f"Filters: Items sorted by language priority and then by quality")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Filters: Error while applying language priority filter", exc_info=e)
|
||||||
|
|
||||||
logger.success(f"Filters: Filtering complete. Final item count: {len(items)}")
|
logger.success(f"Filters: Filtering complete. Final item count: {len(items)}")
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
@ -254,9 +358,15 @@ def merge_items(
|
||||||
merged_dict = {}
|
merged_dict = {}
|
||||||
|
|
||||||
def add_to_merged(item: TorrentItem):
|
def add_to_merged(item: TorrentItem):
|
||||||
key = (item.raw_title, item.size)
|
key = (item.raw_title, item.size, item.privacy)
|
||||||
if key not in merged_dict or item.seeders > merged_dict[key].seeders:
|
if key not in merged_dict:
|
||||||
merged_dict[key] = item
|
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)):
|
||||||
|
merged_dict[key] = item
|
||||||
|
|
||||||
for item in cache_items:
|
for item in cache_items:
|
||||||
add_to_merged(item)
|
add_to_merged(item)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict
|
||||||
from RTN import parse
|
from RTN import parse
|
||||||
|
import re
|
||||||
|
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
@ -20,6 +21,57 @@ def season_episode_in_filename(filename, season, episode):
|
||||||
return season in parsed_name.seasons and episode in parsed_name.episodes
|
return season in parsed_name.seasons and episode in parsed_name.episodes
|
||||||
|
|
||||||
|
|
||||||
|
def smart_episode_fallback(files: List[Dict], season: int, episode: int) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Fallback intelligent pour trouver l'épisode correct quand RTN échoue.
|
||||||
|
Essaie plusieurs stratégies avant de prendre le plus gros fichier.
|
||||||
|
"""
|
||||||
|
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"{season:02d}x{episode:02d}", # 01x01
|
||||||
|
rf"{season}x{episode:02d}", # 1x01
|
||||||
|
]
|
||||||
|
|
||||||
|
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())
|
||||||
|
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')}")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def get_info_hash_from_magnet(magnet: str):
|
def get_info_hash_from_magnet(magnet: str):
|
||||||
exact_topic_index = magnet.find("xt=")
|
exact_topic_index = magnet.find("xt=")
|
||||||
if exact_topic_index == -1:
|
if exact_topic_index == -1:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import os
|
import os
|
||||||
import queue
|
import asyncio
|
||||||
import threading
|
import aiohttp
|
||||||
import time
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
import requests
|
|
||||||
from RTN import parse
|
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_indexer import JackettIndexer
|
||||||
from stream_fusion.utils.jackett.jackett_result import JackettResult
|
from stream_fusion.utils.jackett.jackett_result import JackettResult
|
||||||
|
|
@ -19,77 +16,83 @@ from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
class JackettService:
|
class JackettService:
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
self.__api_key = settings.jackett_api_key
|
self.__api_key = settings.jackett_api_key
|
||||||
self.__base_url = f"{settings.jackett_schema}://{settings.jackett_host}:{settings.jackett_port}/api/v2.0"
|
self.__base_url = f"{settings.jackett_schema}://{settings.jackett_host}:{settings.jackett_port}/api/v2.0"
|
||||||
self.__session = requests.Session()
|
|
||||||
|
|
||||||
adapter = HTTPAdapter(pool_connections=100, pool_maxsize=100)
|
|
||||||
|
|
||||||
retry_strategy = Retry(
|
|
||||||
total=3,
|
|
||||||
backoff_factor=1,
|
|
||||||
status_forcelist=[429, 500, 502, 503, 504],
|
|
||||||
allowed_methods=["HEAD", "GET", "OPTIONS"]
|
|
||||||
)
|
|
||||||
adapter.max_retries = retry_strategy
|
|
||||||
|
|
||||||
self.__session.mount("http://", adapter)
|
|
||||||
self.__session.mount("https://", adapter)
|
|
||||||
|
|
||||||
def search(self, media):
|
self._external_session = session is not None
|
||||||
|
self._session = session
|
||||||
|
self._timeout = aiohttp.ClientTimeout(total=30)
|
||||||
|
|
||||||
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
"""Retourne la session aiohttp, en crée une si nécessaire."""
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
||||||
|
self._external_session = False
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Ferme la session si elle a été créée en interne."""
|
||||||
|
if self._session and not self._external_session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def search(self, media) -> List[JackettResult]:
|
||||||
self.logger.info("Started Jackett search for " + media.type + " " + media.titles[0])
|
self.logger.info("Started Jackett search for " + media.type + " " + media.titles[0])
|
||||||
|
|
||||||
indexers = self.__get_indexers()
|
indexers = await self.__get_indexers()
|
||||||
threads = []
|
|
||||||
results_queue = queue.Queue() # Create a Queue instance to hold the results
|
|
||||||
|
|
||||||
# Define a wrapper function that calls the actual target function and stores its return value in the queue
|
if isinstance(media, Movie):
|
||||||
def thread_target(media, indexer):
|
search_func = self.__search_movie_indexer
|
||||||
self.logger.info(f"Searching on {indexer.title}")
|
elif isinstance(media, Series):
|
||||||
start_time = time.time()
|
search_func = self.__search_series_indexer
|
||||||
|
else:
|
||||||
# Call the actual function
|
raise TypeError("Only Movie and Series is allowed as media!")
|
||||||
if isinstance(media, Movie):
|
|
||||||
result = self.__search_movie_indexer(media, indexer)
|
|
||||||
elif isinstance(media, Series):
|
|
||||||
result = self.__search_series_indexer(media, indexer)
|
|
||||||
else:
|
|
||||||
raise TypeError("Only Movie and Series is allowed as media!")
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Search on {indexer.title} took {time.time() - start_time} seconds and found {len([result for sublist in result for result in sublist])} results")
|
|
||||||
|
|
||||||
results_queue.put(result) # Put the result in the queue
|
|
||||||
|
|
||||||
|
# Lancer toutes les recherches en parallèle avec asyncio.gather()
|
||||||
|
import time
|
||||||
|
tasks = []
|
||||||
for indexer in indexers:
|
for indexer in indexers:
|
||||||
# Pass the wrapper function as the target to Thread, with necessary arguments
|
tasks.append(self.__search_indexer_wrapper(media, indexer, search_func))
|
||||||
threads.append(threading.Thread(target=thread_target, args=(media, indexer)))
|
|
||||||
|
|
||||||
for thread in threads:
|
results_nested = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
thread.start()
|
|
||||||
|
|
||||||
for thread in threads:
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
|
# Aplatir les résultats
|
||||||
results = []
|
results = []
|
||||||
|
for result in results_nested:
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
self.logger.exception(f"Error in Jackett search: {result}")
|
||||||
|
elif result:
|
||||||
|
for sublist in result:
|
||||||
|
if sublist:
|
||||||
|
results.extend(sublist)
|
||||||
|
|
||||||
# Retrieve results from the queue and append them to the results list
|
return self.__post_process_results(results, media)
|
||||||
while not results_queue.empty():
|
|
||||||
results.extend(results_queue.get())
|
|
||||||
|
|
||||||
flatten_results = [result for sublist in results for result in sublist]
|
async def __search_indexer_wrapper(self, media, indexer, search_func):
|
||||||
|
"""Wrapper pour mesurer le temps de recherche par indexer."""
|
||||||
|
import time
|
||||||
|
self.logger.info(f"Searching on {indexer.title}")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
return self.__post_process_results(flatten_results, media)
|
try:
|
||||||
|
result = await search_func(media, indexer)
|
||||||
|
count = len([r for sublist in result for r in sublist]) if result else 0
|
||||||
|
self.logger.info(
|
||||||
|
f"Search on {indexer.title} took {time.time() - start_time:.2f} seconds and found {count} results"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.exception(f"Error searching on {indexer.title}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def __search_movie_indexer(self, movie, indexer):
|
async def __search_movie_indexer(self, movie: Movie, indexer: JackettIndexer) -> List[List[JackettResult]]:
|
||||||
|
has_imdb_search_capability = (
|
||||||
# url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=movie&cat=2000&q={movie.title}&year={movie.year}"
|
os.getenv("DISABLE_JACKETT_IMDB_SEARCH") != "true"
|
||||||
|
and indexer.movie_search_capatabilities is not None
|
||||||
has_imdb_search_capability = (os.getenv(
|
and 'imdbid' in indexer.movie_search_capatabilities
|
||||||
"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:
|
if has_imdb_search_capability:
|
||||||
languages = ['en']
|
languages = ['en']
|
||||||
|
|
@ -99,12 +102,15 @@ class JackettService:
|
||||||
languages = movie.languages
|
languages = movie.languages
|
||||||
titles = movie.titles
|
titles = movie.titles
|
||||||
else:
|
else:
|
||||||
index_of_language = [index for index, lang in enumerate(movie.languages) if
|
index_of_language = [
|
||||||
lang == indexer.language or lang == 'en']
|
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]
|
languages = [movie.languages[index] for index in index_of_language]
|
||||||
titles = [movie.titles[index] for index in index_of_language]
|
titles = [movie.titles[index] for index in index_of_language]
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
session = await self._get_session()
|
||||||
|
|
||||||
for index, lang in enumerate(languages):
|
for index, lang in enumerate(languages):
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -122,23 +128,28 @@ class JackettService:
|
||||||
url += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
url += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.__session.get(url)
|
async with session.get(url) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
results.append(self.__get_torrent_links_from_xml(response.text))
|
text = await response.text()
|
||||||
|
results.append(self.__get_torrent_links_from_xml(text))
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception(
|
self.logger.exception(
|
||||||
f"An exception occured while searching for a movie on Jackett with indexer {indexer.title} and "
|
f"An exception occurred while searching for a movie on Jackett with indexer {indexer.title} and "
|
||||||
f"language {lang}.")
|
f"language {lang}."
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def __search_series_indexer(self, series, indexer):
|
async def __search_series_indexer(self, series: Series, indexer: JackettIndexer) -> List[List[JackettResult]]:
|
||||||
season = str(int(series.season.replace('S', '')))
|
season = str(int(series.season.replace('S', '')))
|
||||||
episode = str(int(series.episode.replace('E', '')))
|
episode = str(int(series.episode.replace('E', '')))
|
||||||
|
|
||||||
has_imdb_search_capability = (os.getenv("DISABLE_JACKETT_IMDB_SEARCH") != "true"
|
has_imdb_search_capability = (
|
||||||
and indexer.tv_search_capatabilities is not None
|
os.getenv("DISABLE_JACKETT_IMDB_SEARCH") != "true"
|
||||||
and 'imdbid' in indexer.tv_search_capatabilities)
|
and indexer.tv_search_capatabilities is not None
|
||||||
|
and 'imdbid' in indexer.tv_search_capatabilities
|
||||||
|
)
|
||||||
|
|
||||||
if has_imdb_search_capability:
|
if has_imdb_search_capability:
|
||||||
languages = ['en']
|
languages = ['en']
|
||||||
index_of_language = [index for index, lang in enumerate(series.languages) if lang == 'en'][0]
|
index_of_language = [index for index, lang in enumerate(series.languages) if lang == 'en'][0]
|
||||||
|
|
@ -147,12 +158,15 @@ class JackettService:
|
||||||
languages = series.languages
|
languages = series.languages
|
||||||
titles = series.titles
|
titles = series.titles
|
||||||
else:
|
else:
|
||||||
index_of_language = [index for index, lang in enumerate(series.languages) if
|
index_of_language = [
|
||||||
lang == indexer.language or lang == 'en']
|
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]
|
languages = [series.languages[index] for index in index_of_language]
|
||||||
titles = [series.titles[index] for index in index_of_language]
|
titles = [series.titles[index] for index in index_of_language]
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
session = await self._get_session()
|
||||||
|
|
||||||
for index, lang in enumerate(languages):
|
for index, lang in enumerate(languages):
|
||||||
params = {
|
params = {
|
||||||
|
|
@ -169,24 +183,24 @@ class JackettService:
|
||||||
url_title += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
url_title += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
||||||
|
|
||||||
url_season = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
|
url_season = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
|
||||||
params['season'] = season
|
params_season = {**params, 'season': season}
|
||||||
url_season += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
url_season += '?' + '&'.join([f'{k}={v}' for k, v in params_season.items()])
|
||||||
|
|
||||||
url_ep = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
|
url_ep = f"{self.__base_url}/indexers/{indexer.id}/results/torznab/api"
|
||||||
params['ep'] = episode
|
params_ep = {**params_season, 'ep': episode}
|
||||||
url_ep += '?' + '&'.join([f'{k}={v}' for k, v in params.items()])
|
url_ep += '?' + '&'.join([f'{k}={v}' for k, v in params_ep.items()])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Current functionality is that it returns if the season, episode search was successful. This is subject to change
|
# Lancer les 3 requêtes en parallèle
|
||||||
# TODO: what should we prioritize? season, episode or title?
|
async with session.get(url_ep) as response_ep:
|
||||||
response_ep = self.__session.get(url_ep)
|
response_ep.raise_for_status()
|
||||||
response_ep.raise_for_status()
|
text_ep = await response_ep.text()
|
||||||
|
data_ep = self.__get_torrent_links_from_xml(text_ep)
|
||||||
|
|
||||||
response_season = self.__session.get(url_season)
|
async with session.get(url_season) as response_season:
|
||||||
response_season.raise_for_status()
|
response_season.raise_for_status()
|
||||||
|
text_season = await response_season.text()
|
||||||
data_ep = self.__get_torrent_links_from_xml(response_ep.text)
|
data_season = self.__get_torrent_links_from_xml(text_season)
|
||||||
data_season = self.__get_torrent_links_from_xml(response_season.text)
|
|
||||||
|
|
||||||
if data_ep:
|
if data_ep:
|
||||||
results.append(data_ep)
|
results.append(data_ep)
|
||||||
|
|
@ -194,29 +208,33 @@ class JackettService:
|
||||||
results.append(data_season)
|
results.append(data_season)
|
||||||
|
|
||||||
if not data_ep and not data_season:
|
if not data_ep and not data_season:
|
||||||
response_title = self.__session.get(url_title)
|
async with session.get(url_title) as response_title:
|
||||||
response_title.raise_for_status()
|
response_title.raise_for_status()
|
||||||
data_title = self.__get_torrent_links_from_xml(response_title.text)
|
text_title = await response_title.text()
|
||||||
if data_title:
|
data_title = self.__get_torrent_links_from_xml(text_title)
|
||||||
results.append(data_title)
|
if data_title:
|
||||||
|
results.append(data_title)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception(
|
self.logger.exception(
|
||||||
f"An exception occured while searching for a series on Jackett with indexer {indexer.title} and language {lang}.")
|
f"An exception occurred while searching for a series on Jackett with indexer {indexer.title} and language {lang}."
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def __get_indexers(self):
|
async def __get_indexers(self) -> List[JackettIndexer]:
|
||||||
url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=indexers&configured=true"
|
url = f"{self.__base_url}/indexers/all/results/torznab/api?apikey={self.__api_key}&t=indexers&configured=true"
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
try:
|
try:
|
||||||
response = self.__session.get(url)
|
async with session.get(url) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return self.__get_indexer_from_xml(response.text)
|
text = await response.text()
|
||||||
|
return self.__get_indexer_from_xml(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("An exception occured while getting indexers from Jackett.")
|
self.logger.exception("An exception occurred while getting indexers from Jackett.")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def __get_indexer_from_xml(self, xml_content):
|
def __get_indexer_from_xml(self, xml_content: str) -> List[JackettIndexer]:
|
||||||
xml_root = ET.fromstring(xml_content)
|
xml_root = ET.fromstring(xml_content)
|
||||||
|
|
||||||
indexer_list = []
|
indexer_list = []
|
||||||
|
|
@ -248,15 +266,17 @@ class JackettService:
|
||||||
|
|
||||||
return indexer_list
|
return indexer_list
|
||||||
|
|
||||||
def __get_torrent_links_from_xml(self, xml_content):
|
def __get_torrent_links_from_xml(self, xml_content: str) -> List[JackettResult]:
|
||||||
xml_root = ET.fromstring(xml_content)
|
xml_root = ET.fromstring(xml_content)
|
||||||
|
|
||||||
result_list = []
|
result_list = []
|
||||||
for item in xml_root.findall('.//item'):
|
for item in xml_root.findall('.//item'):
|
||||||
result = JackettResult()
|
result = JackettResult()
|
||||||
|
|
||||||
result.seeders = item.find('.//torznab:attr[@name="seeders"]',
|
result.seeders = item.find(
|
||||||
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}).attrib['value']
|
'.//torznab:attr[@name="seeders"]',
|
||||||
|
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}
|
||||||
|
).attrib['value']
|
||||||
if int(result.seeders) <= 0:
|
if int(result.seeders) <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -266,24 +286,26 @@ class JackettService:
|
||||||
result.indexer = item.find('jackettindexer').text
|
result.indexer = item.find('jackettindexer').text
|
||||||
result.privacy = item.find('type').text
|
result.privacy = item.find('type').text
|
||||||
|
|
||||||
# TODO: I haven't seen this in the Jackett XML response. Is this still relevant?
|
magnet = item.find(
|
||||||
# Or which indexers provide this?
|
'.//torznab:attr[@name="magneturl"]',
|
||||||
magnet = item.find('.//torznab:attr[@name="magneturl"]',
|
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'}
|
||||||
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'})
|
)
|
||||||
result.magnet = magnet.attrib['value'] if magnet is not None else None
|
result.magnet = magnet.attrib['value'] if magnet is not None else None
|
||||||
|
|
||||||
infoHash = item.find('.//torznab:attr[@name="infohash"]',
|
infoHash = item.find(
|
||||||
namespaces={'torznab': 'http://torznab.com/schemas/2015/feed'})
|
'.//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.info_hash = infoHash.attrib['value'] if infoHash is not None else None
|
||||||
|
|
||||||
result_list.append(result)
|
result_list.append(result)
|
||||||
|
|
||||||
return result_list
|
return result_list
|
||||||
|
|
||||||
def __post_process_results(self, results, media):
|
def __post_process_results(self, results: List[JackettResult], media) -> List[JackettResult]:
|
||||||
for result in results:
|
for result in results:
|
||||||
parsed_result = parse(result.raw_title)
|
parsed_result = parse(result.raw_title)
|
||||||
|
|
||||||
result.parsed_data = parsed_result
|
result.parsed_data = parsed_result
|
||||||
result.languages = detect_languages(result.raw_title)
|
result.languages = detect_languages(result.raw_title)
|
||||||
result.type = media.type
|
result.type = media.type
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import requests
|
import os
|
||||||
|
import aiohttp
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
|
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
|
||||||
from stream_fusion.utils.models.movie import Movie
|
from stream_fusion.utils.models.movie import Movie
|
||||||
|
|
@ -6,26 +8,81 @@ from stream_fusion.utils.models.series import Series
|
||||||
|
|
||||||
|
|
||||||
class Cinemeta(MetadataProvider):
|
class Cinemeta(MetadataProvider):
|
||||||
def get_metadata(self, id, type):
|
# Manual IMDB to TMDB ID mapping for series without proper IMDB linking on TMDB
|
||||||
|
IMDB_TO_TMDB_MAPPING = {
|
||||||
|
"tt38776705": "272565", # Culte - 2Be3
|
||||||
|
}
|
||||||
|
|
||||||
|
# Manual series title mapping for fallback (when Cinemeta doesn't return series name)
|
||||||
|
SERIES_TITLE_MAPPING = {
|
||||||
|
"tt38776705": "Culte - 2Be3",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_metadata(self, id, type):
|
||||||
self.logger.info("Getting metadata for " + type + " with id " + id)
|
self.logger.info("Getting metadata for " + type + " with id " + id)
|
||||||
|
|
||||||
full_id = id.split(":")
|
full_id = id.split(":")
|
||||||
|
imdb_id = full_id[0]
|
||||||
|
|
||||||
url = f"https://v3-cinemeta.strem.io/meta/{type}/{full_id[0]}.json"
|
session = await self._get_session()
|
||||||
response = requests.get(url)
|
|
||||||
data = response.json()
|
# Requête Cinemeta
|
||||||
|
url = f"https://v3-cinemeta.strem.io/meta/{type}/{imdb_id}.json"
|
||||||
|
async with session.get(url) as response:
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
|
# Handle missing name field - use fallback if needed
|
||||||
|
title = None
|
||||||
|
if "name" in data.get("meta", {}):
|
||||||
|
title = self.replace_weird_characters(data["meta"]["name"])
|
||||||
|
elif type == "series":
|
||||||
|
# Check manual series title mapping first
|
||||||
|
if imdb_id in self.SERIES_TITLE_MAPPING:
|
||||||
|
title = self.SERIES_TITLE_MAPPING[imdb_id]
|
||||||
|
self.logger.info(f"Using manual series title mapping: {imdb_id} → {title}")
|
||||||
|
elif "videos" in data.get("meta", {}) and data["meta"]["videos"]:
|
||||||
|
# Fallback: use first video/episode name as series title
|
||||||
|
first_video = data["meta"]["videos"][0] if isinstance(data["meta"]["videos"], list) else None
|
||||||
|
if first_video:
|
||||||
|
episode_name = first_video.get("name", "Unknown")
|
||||||
|
title = self.replace_weird_characters(episode_name)
|
||||||
|
else:
|
||||||
|
title = "Unknown"
|
||||||
|
else:
|
||||||
|
title = "Unknown"
|
||||||
|
|
||||||
|
# Check manual mapping for TMDB ID first
|
||||||
|
tmdb_id = self.IMDB_TO_TMDB_MAPPING.get(imdb_id, None)
|
||||||
|
|
||||||
|
# If not in manual mapping, try to find TMDB ID from title using TMDB API
|
||||||
|
if not tmdb_id:
|
||||||
|
try:
|
||||||
|
tmdb_api_key = os.environ.get("TMDB_API_KEY", "")
|
||||||
|
if title and title != "Unknown" and tmdb_api_key:
|
||||||
|
tmdb_url = f"https://api.themoviedb.org/3/search/{type}?query={title}&api_key={tmdb_api_key}"
|
||||||
|
async with session.get(tmdb_url, timeout=aiohttp.ClientTimeout(total=5)) as tmdb_response:
|
||||||
|
tmdb_data = await tmdb_response.json()
|
||||||
|
if tmdb_data.get("results"):
|
||||||
|
tmdb_id = str(tmdb_data["results"][0]["id"])
|
||||||
|
self.logger.info(f"Found TMDB ID {tmdb_id} for {type} '{title}'")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Failed to find TMDB ID for '{title}': {e}")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Using manual mapping: IMDB {imdb_id} → TMDB {tmdb_id}")
|
||||||
|
|
||||||
if type == "movie":
|
if type == "movie":
|
||||||
result = Movie(
|
result = Movie(
|
||||||
id=id,
|
id=id,
|
||||||
titles=[self.replace_weird_characters(data["meta"]["name"])],
|
tmdb_id=tmdb_id,
|
||||||
year=data["meta"]["year"],
|
titles=[title],
|
||||||
|
year=data["meta"].get("year", 2024),
|
||||||
languages=["en"]
|
languages=["en"]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = Series(
|
result = Series(
|
||||||
id=id,
|
id=id,
|
||||||
titles=[self.replace_weird_characters(data["meta"]["name"])],
|
tmdb_id=tmdb_id,
|
||||||
|
titles=[title],
|
||||||
season="S{:02d}".format(int(full_id[1])),
|
season="S{:02d}".format(int(full_id[1])),
|
||||||
episode="E{:02d}".format(int(full_id[2])),
|
episode="E{:02d}".format(int(full_id[2])),
|
||||||
languages=["en"]
|
languages=["en"]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,27 @@
|
||||||
|
import aiohttp
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
class MetadataProvider:
|
class MetadataProvider:
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.logger = logger
|
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):
|
def replace_weird_characters(self, string):
|
||||||
corresp = {
|
corresp = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import requests
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
|
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider
|
||||||
from stream_fusion.utils.models.movie import Movie
|
from stream_fusion.utils.models.movie import Movie
|
||||||
|
|
@ -6,22 +8,30 @@ from stream_fusion.utils.models.series import Series
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
class TMDB(MetadataProvider):
|
class TMDB(MetadataProvider):
|
||||||
def get_metadata(self, id, type):
|
async def get_metadata(self, id, type):
|
||||||
self.logger.info("Getting metadata for " + type + " with id " + id)
|
self.logger.info("Getting metadata for " + type + " with id " + id)
|
||||||
|
|
||||||
full_id = id.split(":")
|
full_id = id.split(":")
|
||||||
|
session = await self._get_session()
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
for lang in self.config['languages']:
|
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}"
|
url = f"https://api.themoviedb.org/3/find/{full_id[0]}?api_key={settings.tmdb_api_key}&external_source=imdb_id&language={lang}"
|
||||||
response = requests.get(url)
|
|
||||||
data = response.json()
|
async with session.get(url) as response:
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
logger.trace(data)
|
logger.trace(data)
|
||||||
|
|
||||||
if lang == self.config['languages'][0]:
|
if lang == self.config['languages'][0]:
|
||||||
if type == "movie":
|
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(
|
result = Movie(
|
||||||
id=id,
|
id=id,
|
||||||
tmdb_id=data["movie_results"][0]["id"],
|
tmdb_id=data["movie_results"][0]["id"],
|
||||||
|
|
@ -30,19 +40,29 @@ class TMDB(MetadataProvider):
|
||||||
languages=self.config['languages']
|
languages=self.config['languages']
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Vérifier si tv_results n'est pas vide
|
||||||
|
if not data.get("tv_results") or len(data["tv_results"]) == 0:
|
||||||
|
raise ValueError(f"No TMDB results found for series with IMDB ID {full_id[0]}")
|
||||||
|
|
||||||
|
tmdb_id = data["tv_results"][0]["id"]
|
||||||
|
season_num = int(full_id[1])
|
||||||
|
episode_num = int(full_id[2])
|
||||||
|
|
||||||
result = Series(
|
result = Series(
|
||||||
id=id,
|
id=id,
|
||||||
tmdb_id = data["tv_results"][0]["id"],
|
tmdb_id=tmdb_id,
|
||||||
titles=[self.replace_weird_characters(data["tv_results"][0]["name"])],
|
titles=[self.replace_weird_characters(data["tv_results"][0]["name"])],
|
||||||
season="S{:02d}".format(int(full_id[1])),
|
season="S{:02d}".format(season_num),
|
||||||
episode="E{:02d}".format(int(full_id[2])),
|
episode="E{:02d}".format(episode_num),
|
||||||
languages=self.config['languages']
|
languages=self.config['languages']
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if type == "movie":
|
if type == "movie":
|
||||||
result.titles.append(self.replace_weird_characters(data["movie_results"][0]["title"]))
|
if data.get("movie_results") and len(data["movie_results"]) > 0:
|
||||||
|
result.titles.append(self.replace_weird_characters(data["movie_results"][0]["title"]))
|
||||||
else:
|
else:
|
||||||
result.titles.append(self.replace_weird_characters(data["tv_results"][0]["name"]))
|
if data.get("tv_results") and len(data["tv_results"]) > 0:
|
||||||
|
result.titles.append(self.replace_weird_characters(data["tv_results"][0]["name"]))
|
||||||
|
|
||||||
self.logger.info("Got metadata for " + type + " with id " + id)
|
self.logger.info("Got metadata for " + type + " with id " + id)
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,15 @@ class Series(Media):
|
||||||
self.season = season
|
self.season = season
|
||||||
self.episode = episode
|
self.episode = episode
|
||||||
self.seasonfile = None
|
self.seasonfile = None
|
||||||
|
|
||||||
|
def get_season_number(self) -> int:
|
||||||
|
"""Extract season number from season string (e.g., 'S02' -> 2)"""
|
||||||
|
if self.season and self.season.startswith('S'):
|
||||||
|
return int(self.season[1:])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_episode_number(self) -> int:
|
||||||
|
"""Extract episode number from episode string (e.g., 'E05' -> 5)"""
|
||||||
|
if self.episode and self.episode.startswith('E'):
|
||||||
|
return int(self.episode[1:])
|
||||||
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,16 @@ def parse_config(b64config):
|
||||||
if "addonHost" not in config:
|
if "addonHost" not in config:
|
||||||
logger.warning("addonHost not found in config, using default")
|
logger.warning("addonHost not found in config, using default")
|
||||||
config["addonHost"] = "http://127.0.0.1:8000"
|
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
|
return config
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import json
|
import json
|
||||||
import queue
|
import asyncio
|
||||||
import threading
|
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
|
||||||
from RTN import ParsedData
|
from RTN import ParsedData
|
||||||
|
|
@ -26,37 +25,73 @@ class StreamParser:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.configb64 = encodeb64(json.dumps(config).replace("=", "%3D"))
|
self.configb64 = encodeb64(json.dumps(config).replace("=", "%3D"))
|
||||||
|
|
||||||
def parse_to_stremio_streams(
|
async def parse_to_stremio_streams(
|
||||||
self, torrent_items: List[TorrentItem], media: Media
|
self, torrent_items: List[TorrentItem], media: Media
|
||||||
) -> List[Dict]:
|
) -> 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 = []
|
stream_list = []
|
||||||
threads = []
|
for result in stream_results:
|
||||||
thread_results_queue = queue.Queue()
|
if isinstance(result, Exception):
|
||||||
|
# Log mais continue
|
||||||
for torrent_item in torrent_items[: int(self.config["maxResults"])]:
|
continue
|
||||||
thread = threading.Thread(
|
if isinstance(result, list):
|
||||||
target=self._parse_to_debrid_stream,
|
stream_list.extend(result)
|
||||||
args=(torrent_item, thread_results_queue, media),
|
elif result:
|
||||||
daemon=True,
|
stream_list.append(result)
|
||||||
)
|
|
||||||
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"]:
|
if self.config["debrid"]:
|
||||||
stream_list = sorted(stream_list, key=filter_by_availability)
|
is_torbox = (bool(self.config.get("TBToken")) or self.config.get("debridDownloader") == "TorBox")
|
||||||
stream_list = sorted(stream_list, key=filter_by_direct_torrent)
|
|
||||||
|
def combined_sort_key(item):
|
||||||
|
name = item["name"]
|
||||||
|
desc = item.get("description", "")
|
||||||
|
if name.startswith(DIRECT_TORRENT):
|
||||||
|
return 0
|
||||||
|
if INSTANTLY_AVAILABLE in name:
|
||||||
|
return 1
|
||||||
|
if is_torbox and ("C411" in desc or "Torr9" in desc):
|
||||||
|
return 2
|
||||||
|
if "C411" in desc or "Torr9" in desc:
|
||||||
|
return 4
|
||||||
|
return 3
|
||||||
|
|
||||||
|
stream_list = sorted(stream_list, key=combined_sort_key)
|
||||||
|
|
||||||
return stream_list
|
return stream_list
|
||||||
|
|
||||||
def _parse_to_debrid_stream(
|
async def _parse_to_debrid_stream_async(
|
||||||
self, torrent_item: TorrentItem, results: queue.Queue, media: Media
|
self, torrent_item: TorrentItem, media: Media
|
||||||
) -> None:
|
) -> List[Dict]:
|
||||||
|
"""Version async qui retourne une liste de streams"""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self._parse_to_debrid_stream_sync, torrent_item, media
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_to_debrid_stream_sync(
|
||||||
|
self, torrent_item: TorrentItem, media: Media
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Version synchrone du parsing (CPU-bound)"""
|
||||||
|
# Ensure parsed_data is valid ParsedData object, not string or dict
|
||||||
|
from RTN import parse
|
||||||
|
|
||||||
|
# Force reparsing if not ParsedData
|
||||||
|
if torrent_item.parsed_data is None or not isinstance(torrent_item.parsed_data, ParsedData):
|
||||||
|
torrent_item.parsed_data = parse(torrent_item.raw_title)
|
||||||
|
|
||||||
parsed_data: ParsedData = torrent_item.parsed_data
|
parsed_data: ParsedData = torrent_item.parsed_data
|
||||||
name = self._create_stream_name(torrent_item, parsed_data)
|
name = self._create_stream_name(torrent_item, parsed_data)
|
||||||
title = self._create_stream_title(torrent_item, parsed_data, media)
|
title = self._create_stream_title(torrent_item, parsed_data, media)
|
||||||
|
|
@ -65,25 +100,55 @@ class StreamParser:
|
||||||
json.dumps(torrent_item.to_debrid_stream_query(media))
|
json.dumps(torrent_item.to_debrid_stream_query(media))
|
||||||
).replace("=", "%3D")
|
).replace("=", "%3D")
|
||||||
|
|
||||||
results.put(
|
results = []
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"description": title,
|
|
||||||
"url": f"{self.config['addonHost']}/playback/{self.configb64}/{queryb64}",
|
|
||||||
"behaviorHints": {
|
|
||||||
"bingeGroup": f"stream-fusion-{torrent_item.info_hash}",
|
|
||||||
"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":
|
if self.config["torrenting"] and torrent_item.privacy == "public":
|
||||||
self._add_direct_torrent_stream(torrent_item, parsed_data, title, results)
|
direct_stream = self._create_direct_torrent_stream(torrent_item, parsed_data, title, media)
|
||||||
|
if direct_stream:
|
||||||
|
results.append(direct_stream)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _generate_binge_group(self, torrent_item: TorrentItem, media: Media) -> str:
|
||||||
|
"""Génère un bingeGroup intelligent selon le type de média"""
|
||||||
|
|
||||||
|
if media.type == "movie":
|
||||||
|
return f"stream-fusion-{torrent_item.info_hash}"
|
||||||
|
|
||||||
|
if media.type == "series":
|
||||||
|
# Pour les séries, utiliser l'ID de la série + résolution + team pour permettre
|
||||||
|
# la lecture automatique même avec des torrents d'épisodes individuels
|
||||||
|
series_id = media.id.split(":")[0] if ":" in media.id else media.id
|
||||||
|
resolution = torrent_item.parsed_data.resolution if torrent_item.parsed_data.resolution else "Unknown"
|
||||||
|
|
||||||
|
# Ajouter la team si disponible pour une meilleure granularité
|
||||||
|
team = extract_release_group(torrent_item.raw_title) or torrent_item.parsed_data.group
|
||||||
|
if team:
|
||||||
|
return f"stream-fusion-{series_id}-{resolution}-{team}"
|
||||||
|
else:
|
||||||
|
return f"stream-fusion-{series_id}-{resolution}"
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return f"stream-fusion-{torrent_item.info_hash}"
|
||||||
|
|
||||||
def _create_stream_name(
|
def _create_stream_name(
|
||||||
self, torrent_item: TorrentItem, parsed_data: ParsedData
|
self, torrent_item: TorrentItem, parsed_data: ParsedData
|
||||||
) -> str:
|
) -> str:
|
||||||
resolution = parsed_data.resolution or "Unknown"
|
resolution = parsed_data.resolution or "Unknown"
|
||||||
|
# Services de debrid principaux
|
||||||
if torrent_item.availability == "RD":
|
if torrent_item.availability == "RD":
|
||||||
name = f"{INSTANTLY_AVAILABLE}instant\nReal-Debrid\n({resolution})"
|
name = f"{INSTANTLY_AVAILABLE}instant\nReal-Debrid\n({resolution})"
|
||||||
elif torrent_item.availability == "AD":
|
elif torrent_item.availability == "AD":
|
||||||
|
|
@ -92,6 +157,15 @@ class StreamParser:
|
||||||
name = f"{INSTANTLY_AVAILABLE}instant\nTorBox\n({resolution})"
|
name = f"{INSTANTLY_AVAILABLE}instant\nTorBox\n({resolution})"
|
||||||
elif torrent_item.availability == "PM":
|
elif torrent_item.availability == "PM":
|
||||||
name = f"{INSTANTLY_AVAILABLE}instant\nPremiumize\n({resolution})"
|
name = f"{INSTANTLY_AVAILABLE}instant\nPremiumize\n({resolution})"
|
||||||
|
# Services de debrid additionnels
|
||||||
|
elif torrent_item.availability == "OC":
|
||||||
|
name = f"{INSTANTLY_AVAILABLE}instant\nOffcloud\n({resolution})"
|
||||||
|
elif torrent_item.availability == "DL":
|
||||||
|
name = f"{INSTANTLY_AVAILABLE}instant\nDebridLink\n({resolution})"
|
||||||
|
elif torrent_item.availability == "ED":
|
||||||
|
name = f"{INSTANTLY_AVAILABLE}instant\nEasyDebrid\n({resolution})"
|
||||||
|
elif torrent_item.availability == "PK":
|
||||||
|
name = f"{INSTANTLY_AVAILABLE}instant\nPikPak\n({resolution})"
|
||||||
else:
|
else:
|
||||||
name = f"{DOWNLOAD_REQUIRED}download\n{self.config.get("debridDownloader", settings.download_service)}\n({resolution})"
|
name = f"{DOWNLOAD_REQUIRED}download\n{self.config.get("debridDownloader", settings.download_service)}\n({resolution})"
|
||||||
return name
|
return name
|
||||||
|
|
@ -99,10 +173,8 @@ class StreamParser:
|
||||||
def _create_stream_title(
|
def _create_stream_title(
|
||||||
self, torrent_item: TorrentItem, parsed_data: ParsedData, media: Media
|
self, torrent_item: TorrentItem, parsed_data: ParsedData, media: Media
|
||||||
) -> str:
|
) -> str:
|
||||||
title = f"{torrent_item.raw_title}\n"
|
title = f"{torrent_item.file_name}\n" if torrent_item.file_name else f"{torrent_item.raw_title}\n"
|
||||||
|
|
||||||
if media.type == "series" and torrent_item.file_name:
|
|
||||||
title += f"{torrent_item.file_name}\n"
|
|
||||||
|
|
||||||
title += self._add_language_info(torrent_item, parsed_data)
|
title += self._add_language_info(torrent_item, parsed_data)
|
||||||
title += self._add_torrent_info(torrent_item)
|
title += self._add_torrent_info(torrent_item)
|
||||||
|
|
@ -139,32 +211,38 @@ class StreamParser:
|
||||||
info.append(f"🎥 {parsed_data.codec}")
|
info.append(f"🎥 {parsed_data.codec}")
|
||||||
if parsed_data.quality:
|
if parsed_data.quality:
|
||||||
info.append(f"📺 {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:
|
if parsed_data.audio:
|
||||||
info.append(f"🎧 {' '.join(parsed_data.audio)}")
|
info.append(f"🎧 {' '.join(parsed_data.audio)}")
|
||||||
return " ".join(info) + "\n" if info else ""
|
return " ".join(info) + "\n" if info else ""
|
||||||
|
|
||||||
def _add_direct_torrent_stream(
|
def _create_direct_torrent_stream(
|
||||||
self,
|
self,
|
||||||
torrent_item: TorrentItem,
|
torrent_item: TorrentItem,
|
||||||
parsed_data: ParsedData,
|
parsed_data: ParsedData,
|
||||||
title: str,
|
title: str,
|
||||||
results: queue.Queue,
|
media: Media,
|
||||||
) -> None:
|
) -> Dict:
|
||||||
|
"""Crée un stream direct torrent"""
|
||||||
direct_torrent_name = f"{DIRECT_TORRENT}\n{parsed_data.quality}\n"
|
direct_torrent_name = f"{DIRECT_TORRENT}\n{parsed_data.quality}\n"
|
||||||
if parsed_data.quality and parsed_data.quality[0] not in ["Unknown", ""]:
|
if parsed_data.quality and parsed_data.quality[0] not in ["Unknown", ""]:
|
||||||
direct_torrent_name += f"({'|'.join(parsed_data.quality)})"
|
direct_torrent_name += f"({'|'.join(parsed_data.quality)})"
|
||||||
|
|
||||||
results.put(
|
return {
|
||||||
{
|
"name": direct_torrent_name,
|
||||||
"name": direct_torrent_name,
|
"description": title,
|
||||||
"description": title,
|
"infoHash": torrent_item.info_hash,
|
||||||
"infoHash": torrent_item.info_hash,
|
"fileIdx": (
|
||||||
"fileIdx": (
|
int(torrent_item.file_index) if torrent_item.file_index else None
|
||||||
int(torrent_item.file_index) if torrent_item.file_index else None
|
),
|
||||||
),
|
"behaviorHints": {
|
||||||
"behaviorHints": {
|
"bingeGroup": self._generate_binge_group(torrent_item, media),
|
||||||
"bingeGroup": f"stream-fusion-{torrent_item.info_hash}",
|
"filename": torrent_item.file_name or torrent_item.raw_title,
|
||||||
"filename": torrent_item.file_name or torrent_item.raw_title,
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
|
||||||
0
stream_fusion/utils/session_manager.py
Normal file
0
stream_fusion/utils/session_manager.py
Normal file
|
|
@ -1,74 +1,75 @@
|
||||||
import time
|
import asyncio
|
||||||
from urllib3.util.retry import Retry
|
import aiohttp
|
||||||
import requests
|
from typing import Optional
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
|
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
class AsyncRateLimiter:
|
||||||
def __init__(self, calls_per_second=1):
|
"""Rate limiter async pour contrôler le débit des requêtes."""
|
||||||
self.calls_per_second = calls_per_second
|
|
||||||
self.last_call = 0
|
|
||||||
|
|
||||||
def __call__(self, func):
|
def __init__(self, calls_per_second: float = 1.0):
|
||||||
def wrapper(*args, **kwargs):
|
self.min_interval = 1.0 / calls_per_second
|
||||||
|
self.last_call = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def acquire(self):
|
||||||
|
"""Attend le temps nécessaire avant d'autoriser une nouvelle requête."""
|
||||||
|
async with self._lock:
|
||||||
|
import time
|
||||||
now = time.time()
|
now = time.time()
|
||||||
time_since_last_call = now - self.last_call
|
time_since_last_call = now - self.last_call
|
||||||
if time_since_last_call < 1 / self.calls_per_second:
|
if time_since_last_call < self.min_interval:
|
||||||
time.sleep(1 / self.calls_per_second - time_since_last_call)
|
await asyncio.sleep(self.min_interval - time_since_last_call)
|
||||||
self.last_call = time.time()
|
self.last_call = time.time()
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class SharewoodAPI:
|
class SharewoodAPI:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
sharewood_passkey: str,
|
sharewood_passkey: str,
|
||||||
pool_connections=10,
|
session: Optional[aiohttp.ClientSession] = None,
|
||||||
pool_maxsize=10,
|
timeout: int = 10,
|
||||||
max_retries=3,
|
|
||||||
timeout=10,
|
|
||||||
):
|
):
|
||||||
self.base_url = f"{settings.sharewood_url}/api"
|
self.base_url = f"{settings.sharewood_url}/api"
|
||||||
if not sharewood_passkey or len(sharewood_passkey) != 32:
|
if not sharewood_passkey or len(sharewood_passkey) != 32:
|
||||||
raise ValueError("Sharewood passkey must be 32 characters long")
|
raise ValueError("Sharewood passkey must be 32 characters long")
|
||||||
self.sharewood_passkey = sharewood_passkey
|
self.sharewood_passkey = sharewood_passkey
|
||||||
self.timeout = timeout
|
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
||||||
self.session = requests.Session()
|
self.rate_limiter = AsyncRateLimiter(calls_per_second=1)
|
||||||
self.rate_limiter = RateLimiter(calls_per_second=1)
|
|
||||||
|
|
||||||
retry_strategy = Retry(
|
self._external_session = session is not None
|
||||||
total=max_retries,
|
self._session = session
|
||||||
backoff_factor=0.1,
|
|
||||||
status_forcelist=[429, 500, 502, 503, 504],
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
allowed_methods=["HEAD", "GET", "OPTIONS"],
|
"""Retourne la session aiohttp, en crée une si nécessaire."""
|
||||||
)
|
if self._session is None or self._session.closed:
|
||||||
adapter = HTTPAdapter(
|
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
||||||
pool_connections=pool_connections,
|
self._external_session = False
|
||||||
pool_maxsize=pool_maxsize,
|
return self._session
|
||||||
max_retries=retry_strategy,
|
|
||||||
)
|
async def close(self):
|
||||||
self.session.mount("http://", adapter)
|
"""Ferme la session si elle a été créée en interne."""
|
||||||
self.session.mount("https://", adapter)
|
if self._session and not self._external_session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def _make_request(self, method: str, endpoint: str, params: dict = None):
|
||||||
|
"""Effectue une requête HTTP async avec rate limiting."""
|
||||||
|
await self.rate_limiter.acquire()
|
||||||
|
|
||||||
@RateLimiter()
|
|
||||||
def _make_request(self, method, endpoint, params=None):
|
|
||||||
url = f"{self.base_url}/{self.sharewood_passkey}/{endpoint}"
|
url = f"{self.base_url}/{self.sharewood_passkey}/{endpoint}"
|
||||||
|
session = await self._get_session()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.request(
|
async with session.request(method, url, params=params) as response:
|
||||||
method, url, params=params, timeout=self.timeout
|
response.raise_for_status()
|
||||||
)
|
return await response.json()
|
||||||
response.raise_for_status()
|
except aiohttp.ClientError as e:
|
||||||
return response.json()
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logger.error(f"An error occurred during the request: {e}")
|
logger.error(f"An error occurred during the request: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_last_torrents(self, category=None, subcategory=None, limit=25):
|
async def get_last_torrents(self, category: int = None, subcategory: int = None, limit: int = 25):
|
||||||
"""Get the last torrents, optionally filtered by category or subcategory."""
|
"""Get the last torrents, optionally filtered by category or subcategory."""
|
||||||
params = {}
|
params = {}
|
||||||
if category:
|
if category:
|
||||||
|
|
@ -77,57 +78,56 @@ class SharewoodAPI:
|
||||||
params["subcategory"] = subcategory
|
params["subcategory"] = subcategory
|
||||||
if limit and 1 <= limit <= 25:
|
if limit and 1 <= limit <= 25:
|
||||||
params["limit"] = limit
|
params["limit"] = limit
|
||||||
return self._make_request("GET", "last-torrents", params=params)
|
return await self._make_request("GET", "last-torrents", params=params)
|
||||||
|
|
||||||
def search(self, query, category=None, subcategory=None):
|
async def search(self, query: str, category: int = None, subcategory: int = None):
|
||||||
"""Search for torrents, optionally filtered by category or subcategory."""
|
"""Search for torrents, optionally filtered by category or subcategory."""
|
||||||
params = {"name": query}
|
params = {"name": query}
|
||||||
if category:
|
if category:
|
||||||
params["category"] = category
|
params["category"] = category
|
||||||
if subcategory:
|
if subcategory:
|
||||||
params["subcategory"] = subcategory
|
params["subcategory"] = subcategory
|
||||||
return self._make_request("GET", "search", params=params)
|
return await self._make_request("GET", "search", params=params)
|
||||||
|
|
||||||
def get_video_torrents(self, limit=25):
|
async def get_video_torrents(self, limit: int = 25):
|
||||||
"""Get the last video torrents."""
|
"""Get the last video torrents."""
|
||||||
return self.get_last_torrents(category=1, limit=limit)
|
return await self.get_last_torrents(category=1, limit=limit)
|
||||||
|
|
||||||
def get_audio_torrents(self, limit=25):
|
async def get_audio_torrents(self, limit: int = 25):
|
||||||
"""Get the last audio torrents."""
|
"""Get the last audio torrents."""
|
||||||
return self.get_last_torrents(category=2, limit=limit)
|
return await self.get_last_torrents(category=2, limit=limit)
|
||||||
|
|
||||||
def get_application_torrents(self, limit=25):
|
async def get_application_torrents(self, limit: int = 25):
|
||||||
"""Get the last application torrents."""
|
"""Get the last application torrents."""
|
||||||
return self.get_last_torrents(category=3, limit=limit)
|
return await self.get_last_torrents(category=3, limit=limit)
|
||||||
|
|
||||||
def get_ebook_torrents(self, limit=25):
|
async def get_ebook_torrents(self, limit: int = 25):
|
||||||
"""Get the last ebook torrents."""
|
"""Get the last ebook torrents."""
|
||||||
return self.get_last_torrents(category=4, limit=limit)
|
return await self.get_last_torrents(category=4, limit=limit)
|
||||||
|
|
||||||
def get_game_torrents(self, limit=25):
|
async def get_game_torrents(self, limit: int = 25):
|
||||||
"""Get the last game torrents."""
|
"""Get the last game torrents."""
|
||||||
return self.get_last_torrents(category=5, limit=limit)
|
return await self.get_last_torrents(category=5, limit=limit)
|
||||||
|
|
||||||
def get_training_torrents(self, limit=25):
|
async def get_training_torrents(self, limit: int = 25):
|
||||||
"""Get the last training torrents."""
|
"""Get the last training torrents."""
|
||||||
return self.get_last_torrents(category=6, limit=limit)
|
return await self.get_last_torrents(category=6, limit=limit)
|
||||||
|
|
||||||
def get_adult_torrents(self, limit=25):
|
async def get_adult_torrents(self, limit: int = 25):
|
||||||
"""Get the last adult torrents."""
|
"""Get the last adult torrents."""
|
||||||
return self.get_last_torrents(category=7, limit=limit)
|
return await self.get_last_torrents(category=7, limit=limit)
|
||||||
|
|
||||||
@RateLimiter()
|
async def download_torrent(self, torrent_id: int) -> bytes:
|
||||||
def download_torrent(self, torrent_id):
|
|
||||||
"""Download a specific torrent file."""
|
"""Download a specific torrent file."""
|
||||||
|
await self.rate_limiter.acquire()
|
||||||
|
|
||||||
url = f"{self.base_url}/{self.sharewood_passkey}/{torrent_id}/download"
|
url = f"{self.base_url}/{self.sharewood_passkey}/{torrent_id}/download"
|
||||||
|
session = await self._get_session()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(url, timeout=self.timeout)
|
async with session.get(url) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.content
|
return await response.read()
|
||||||
except requests.exceptions.RequestException as e:
|
except aiohttp.ClientError as e:
|
||||||
logger.error(f"An error occurred while downloading the torrent: {e}")
|
logger.error(f"An error occurred while downloading the torrent: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
"""Close the session when the object is destroyed."""
|
|
||||||
self.session.close()
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import List, Union
|
import aiohttp
|
||||||
|
from typing import List, Union, Optional
|
||||||
from RTN import parse
|
from RTN import parse
|
||||||
|
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
|
@ -13,17 +14,17 @@ from stream_fusion.utils.sharewood.sharewood_api import SharewoodAPI
|
||||||
|
|
||||||
|
|
||||||
class SharewoodService:
|
class SharewoodService:
|
||||||
"""Service for searching media on Sharewood."""
|
"""Service for searching media on Sharewood (async version)."""
|
||||||
|
|
||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict, session: Optional[aiohttp.ClientSession] = None):
|
||||||
self.sharewood_url = settings.sharewood_url
|
self.sharewood_url = settings.sharewood_url
|
||||||
if settings.sharewood_unique_account and settings.sharewood_passkey:
|
if settings.sharewood_unique_account and settings.sharewood_passkey:
|
||||||
self.sharewood_passkey = settings.sharewood_passkey
|
self.sharewood_passkey = settings.sharewood_passkey
|
||||||
else:
|
else:
|
||||||
self.sharewood_passkey = config.get("sharewoodPasskey")
|
self.sharewood_passkey = config.get("sharewoodPasskey")
|
||||||
self.sharewood = SharewoodAPI(self.sharewood_passkey)
|
self.sharewood = SharewoodAPI(self.sharewood_passkey, session=session)
|
||||||
|
|
||||||
def search(self, media: Union[Movie, Series]) -> List[SharewoodResult]:
|
async def search(self, media: Union[Movie, Series]) -> List[SharewoodResult]:
|
||||||
"""
|
"""
|
||||||
Search for a media (movie or series) on sharewood.
|
Search for a media (movie or series) on sharewood.
|
||||||
|
|
||||||
|
|
@ -31,15 +32,15 @@ class SharewoodService:
|
||||||
media (Union[Movie, Series]): The media to search for.
|
media (Union[Movie, Series]): The media to search for.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[sharewoodResult]: List of search results.
|
List[SharewoodResult]: List of search results.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TypeError: If the media type is neither Movie nor Series.
|
TypeError: If the media type is neither Movie nor Series.
|
||||||
"""
|
"""
|
||||||
if isinstance(media, Movie):
|
if isinstance(media, Movie):
|
||||||
results = self.__search_movie(media)
|
results = await self.__search_movie(media)
|
||||||
elif isinstance(media, Series):
|
elif isinstance(media, Series):
|
||||||
results = self.__search_series(media)
|
results = await self.__search_series(media)
|
||||||
else:
|
else:
|
||||||
raise TypeError("Only Movie and Series types are allowed as media!")
|
raise TypeError("Only Movie and Series types are allowed as media!")
|
||||||
|
|
||||||
|
|
@ -59,10 +60,10 @@ class SharewoodService:
|
||||||
"tib": 1024**4,
|
"tib": 1024**4,
|
||||||
"pib": 1024**5,
|
"pib": 1024**5,
|
||||||
}
|
}
|
||||||
|
|
||||||
if isinstance(size, int):
|
if isinstance(size, int):
|
||||||
return size # Si c'est déjà un entier, on le retourne tel quel
|
return size # Si c'est déjà un entier, on le retourne tel quel
|
||||||
|
|
||||||
if isinstance(size, str):
|
if isinstance(size, str):
|
||||||
size = size.lower().replace(',', '.') # Remplace la virgule par un point pour les nombres décimaux
|
size = size.lower().replace(',', '.') # Remplace la virgule par un point pour les nombres décimaux
|
||||||
parts = size.split()
|
parts = size.split()
|
||||||
|
|
@ -75,7 +76,7 @@ class SharewoodService:
|
||||||
if unit not in units:
|
if unit not in units:
|
||||||
raise ValueError(f"None support unit : {unit}")
|
raise ValueError(f"None support unit : {unit}")
|
||||||
return int(value * units[unit])
|
return int(value * units[unit])
|
||||||
|
|
||||||
raise ValueError(f"unkound size format : {size}")
|
raise ValueError(f"unkound size format : {size}")
|
||||||
|
|
||||||
def __clean_title(self, title):
|
def __clean_title(self, title):
|
||||||
|
|
@ -156,15 +157,17 @@ class SharewoodService:
|
||||||
if not (title.lower() in seen or seen.add(title.lower()))
|
if not (title.lower() in seen or seen.add(title.lower()))
|
||||||
]
|
]
|
||||||
|
|
||||||
def __search_movie(self, movie: Movie) -> List[dict]:
|
async def __search_movie(self, movie: Movie) -> List[dict]:
|
||||||
unique_titles = self.__remove_duplicate_titles(movie.titles)
|
unique_titles = self.__remove_duplicate_titles(movie.titles)
|
||||||
clean_titles = [self.__clean_title(title) for title in unique_titles]
|
clean_titles = [self.__clean_title(title) for title in unique_titles]
|
||||||
results = []
|
results = []
|
||||||
for title in clean_titles:
|
for title in clean_titles:
|
||||||
results.extend(self.sharewood.search(query=title, category=1))
|
search_results = await self.sharewood.search(query=title, category=1)
|
||||||
|
if search_results:
|
||||||
|
results.extend(search_results)
|
||||||
return self.__deduplicate_api_results(results)
|
return self.__deduplicate_api_results(results)
|
||||||
|
|
||||||
def __search_series(self, series: Series) -> List[dict]:
|
async def __search_series(self, series: Series) -> List[dict]:
|
||||||
unique_titles = self.__remove_duplicate_titles(series.titles)
|
unique_titles = self.__remove_duplicate_titles(series.titles)
|
||||||
clean_titles = [self.__clean_title(title) for title in unique_titles]
|
clean_titles = [self.__clean_title(title) for title in unique_titles]
|
||||||
search_texts = clean_titles.copy()
|
search_texts = clean_titles.copy()
|
||||||
|
|
@ -176,12 +179,14 @@ class SharewoodService:
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for text in search_texts:
|
for text in search_texts:
|
||||||
results.extend(self.sharewood.search(query=text, category=1))
|
search_results = await self.sharewood.search(query=text, category=1)
|
||||||
|
if search_results:
|
||||||
|
results.extend(search_results)
|
||||||
return self.__deduplicate_api_results(results)
|
return self.__deduplicate_api_results(results)
|
||||||
|
|
||||||
def __filter_out_no_seeders(self, results: List[dict]) -> List[dict]:
|
def __filter_out_no_seeders(self, results: List[dict]) -> List[dict]:
|
||||||
"""Filter out results with less than 5 seeders."""
|
"""Filter out results with less than 5 seeders."""
|
||||||
return [result for result in results if result.get("seeders", 0) >= 5]
|
return [result for result in results if result.get("seeders", 0) >= 0]
|
||||||
|
|
||||||
def __process_download_link(self, id: int) -> str:
|
def __process_download_link(self, id: int) -> str:
|
||||||
"""Generate the download link for a given torrent."""
|
"""Generate the download link for a given torrent."""
|
||||||
|
|
@ -197,7 +202,7 @@ class SharewoodService:
|
||||||
def __post_process_results(
|
def __post_process_results(
|
||||||
self, results: List[dict], media: Union[Movie, Series]
|
self, results: List[dict], media: Union[Movie, Series]
|
||||||
) -> List[SharewoodResult]:
|
) -> 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:
|
if not results:
|
||||||
logger.info(f"No results found on sharewood for: {media.titles[0]}")
|
logger.info(f"No results found on sharewood for: {media.titles[0]}")
|
||||||
return []
|
return []
|
||||||
|
|
@ -229,3 +234,7 @@ class SharewoodService:
|
||||||
items.append(item)
|
items.append(item)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Ferme les ressources."""
|
||||||
|
await self.sharewood.close()
|
||||||
|
|
|
||||||
0
stream_fusion/utils/torr9/__init__.py
Normal file
0
stream_fusion/utils/torr9/__init__.py
Normal file
152
stream_fusion/utils/torr9/torr9_api.py
Normal file
152
stream_fusion/utils/torr9/torr9_api.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import re
|
||||||
|
import aiohttp
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Torr9RawResult:
|
||||||
|
def __init__(self):
|
||||||
|
self.raw_title: Optional[str] = None
|
||||||
|
self.size: Optional[str] = None
|
||||||
|
self.link: Optional[str] = None
|
||||||
|
self.indexer: str = "Torr9 - API"
|
||||||
|
self.seeders: int = 0
|
||||||
|
self.magnet: Optional[str] = None
|
||||||
|
self.info_hash: Optional[str] = None
|
||||||
|
self.privacy: str = "public"
|
||||||
|
|
||||||
|
|
||||||
|
class Torr9API:
|
||||||
|
TORZNAB_NS = {"torznab": "http://torznab.com/schemas/2015/feed"}
|
||||||
|
|
||||||
|
def __init__(self, session: Optional[aiohttp.ClientSession] = None, api_key: Optional[str] = None):
|
||||||
|
self.base_url = settings.torr9_url.rstrip("/") + "/api/v1/torznab"
|
||||||
|
self.api_key = api_key if api_key is not None else settings.torr9_api_key
|
||||||
|
self._external_session = session is not None
|
||||||
|
self._session = session
|
||||||
|
self._timeout = aiohttp.ClientTimeout(sock_read=2)
|
||||||
|
|
||||||
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
||||||
|
self._external_session = False
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def _request_xml(self, params: dict) -> Optional[str]:
|
||||||
|
if not self.api_key:
|
||||||
|
logger.warning("Torr9: API key not configured (TORR9_API_KEY), skipping request")
|
||||||
|
return None
|
||||||
|
params["apikey"] = self.api_key
|
||||||
|
session = await self._get_session()
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
self.base_url, params=params, allow_redirects=True,
|
||||||
|
timeout=aiohttp.ClientTimeout(sock_read=2, total=5)
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
return await response.text()
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"Torr9: HTTP error: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Torr9: Unexpected error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_xml(self, xml_content: str) -> List[Torr9RawResult]:
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_content)
|
||||||
|
except ET.ParseError as e:
|
||||||
|
logger.error(f"Torr9: XML parse error: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
for item in root.findall(".//item"):
|
||||||
|
try:
|
||||||
|
result = Torr9RawResult()
|
||||||
|
|
||||||
|
seeders_el = item.find(
|
||||||
|
'.//torznab:attr[@name="seeders"]', self.TORZNAB_NS
|
||||||
|
)
|
||||||
|
result.seeders = int(seeders_el.attrib["value"]) if seeders_el is not None else 0
|
||||||
|
|
||||||
|
title_el = item.find("title")
|
||||||
|
if title_el is None or not title_el.text:
|
||||||
|
continue
|
||||||
|
result.raw_title = title_el.text
|
||||||
|
|
||||||
|
size_el = item.find("size")
|
||||||
|
result.size = size_el.text if size_el is not None else "0"
|
||||||
|
|
||||||
|
link_el = item.find("link")
|
||||||
|
result.link = link_el.text if link_el is not None else None
|
||||||
|
|
||||||
|
magnet_el = item.find(
|
||||||
|
'.//torznab:attr[@name="magneturl"]', self.TORZNAB_NS
|
||||||
|
)
|
||||||
|
result.magnet = magnet_el.attrib["value"] if magnet_el is not None else None
|
||||||
|
|
||||||
|
hash_el = item.find(
|
||||||
|
'.//torznab:attr[@name="infohash"]', self.TORZNAB_NS
|
||||||
|
)
|
||||||
|
result.info_hash = hash_el.attrib["value"].lower() if hash_el is not None else None
|
||||||
|
|
||||||
|
type_el = item.find("type")
|
||||||
|
result.privacy = type_el.text if type_el is not None else "public"
|
||||||
|
|
||||||
|
# Extract hash from magnet if missing
|
||||||
|
if not result.info_hash and result.magnet:
|
||||||
|
m = re.search(r"btih:([a-fA-F0-9]{40})", result.magnet, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
result.info_hash = m.group(1).lower()
|
||||||
|
|
||||||
|
if result.info_hash and len(result.info_hash) == 40:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Torr9: Error parsing item: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def search_movie(
|
||||||
|
self,
|
||||||
|
imdb_id: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
) -> List[Torr9RawResult]:
|
||||||
|
params = {"t": "movie", "cat": "2000"}
|
||||||
|
if imdb_id:
|
||||||
|
params["imdbid"] = imdb_id
|
||||||
|
elif title:
|
||||||
|
params["q"] = title
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
xml = await self._request_xml(params)
|
||||||
|
results = self._parse_xml(xml) if xml else []
|
||||||
|
logger.info(f"Torr9: search_movie imdb={imdb_id} -> {len(results)} results")
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def search_series(
|
||||||
|
self,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
imdb_id: Optional[str] = None,
|
||||||
|
season: Optional[int] = None,
|
||||||
|
episode: Optional[int] = None,
|
||||||
|
) -> List[Torr9RawResult]:
|
||||||
|
params = {"t": "tvsearch", "cat": "5000"}
|
||||||
|
if imdb_id:
|
||||||
|
params["imdbid"] = imdb_id
|
||||||
|
elif title:
|
||||||
|
params["q"] = title
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
if season is not None:
|
||||||
|
params["season"] = season
|
||||||
|
if episode is not None:
|
||||||
|
params["ep"] = episode
|
||||||
|
xml = await self._request_xml(params)
|
||||||
|
results = self._parse_xml(xml) if xml else []
|
||||||
|
logger.info(f"Torr9: search_series imdb={imdb_id} q={title} s={season} e={episode} -> {len(results)} results")
|
||||||
|
return results
|
||||||
62
stream_fusion/utils/torr9/torr9_result.py
Normal file
62
stream_fusion/utils/torr9/torr9_result.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
|
||||||
|
from RTN import parse
|
||||||
|
|
||||||
|
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
||||||
|
from stream_fusion.utils.detection import detect_languages
|
||||||
|
from urllib.parse import quote
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Torr9Result:
|
||||||
|
def __init__(self):
|
||||||
|
self.raw_title = None
|
||||||
|
self.size = None
|
||||||
|
self.link = None
|
||||||
|
self.indexer = "Torr9 - API"
|
||||||
|
self.seeders = 0
|
||||||
|
self.magnet = None
|
||||||
|
self.info_hash = None
|
||||||
|
self.privacy = "public"
|
||||||
|
self.languages = None
|
||||||
|
self.type = None
|
||||||
|
self.parsed_data = None
|
||||||
|
self.torrent_download = None
|
||||||
|
|
||||||
|
def convert_to_torrent_item(self):
|
||||||
|
parsed_data = self.parsed_data or parse(self.raw_title)
|
||||||
|
return TorrentItem(
|
||||||
|
raw_title=self.raw_title,
|
||||||
|
size=self.size,
|
||||||
|
magnet=self.magnet,
|
||||||
|
info_hash=self.info_hash.lower() if self.info_hash else None,
|
||||||
|
link=self.link or self.magnet,
|
||||||
|
seeders=self.seeders,
|
||||||
|
languages=self.languages,
|
||||||
|
indexer=self.indexer,
|
||||||
|
privacy=self.privacy,
|
||||||
|
type=self.type,
|
||||||
|
parsed_data=parsed_data,
|
||||||
|
torrent_download=self.torrent_download,
|
||||||
|
tmdb_id=self.tmdb_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def from_api_item(self, api_item, media):
|
||||||
|
self.info_hash = api_item.info_hash.lower() if api_item.info_hash else None
|
||||||
|
if not self.info_hash or len(self.info_hash) != 40:
|
||||||
|
raise ValueError(f"Invalid info_hash: {self.info_hash}")
|
||||||
|
|
||||||
|
parsed = parse(api_item.raw_title)
|
||||||
|
self.raw_title = parsed.raw_title
|
||||||
|
self.parsed_data = parsed
|
||||||
|
self.size = api_item.size or "0"
|
||||||
|
torr9_tracker = f"https://tracker.torr9.xyz/announce/{settings.torr9_api_key}"
|
||||||
|
self.magnet = f"magnet:?xt=urn:btih:{self.info_hash}&dn={self.raw_title}&tr={quote(torr9_tracker, safe='')}"
|
||||||
|
self.link = self.magnet
|
||||||
|
self.seeders = api_item.seeders or 0
|
||||||
|
self.privacy = api_item.privacy or "public"
|
||||||
|
self.languages = detect_languages(self.raw_title, default_language="fr")
|
||||||
|
self.type = media.type
|
||||||
|
self.tmdb_id = getattr(media, 'tmdb_id', None)
|
||||||
|
self.torrent_download = getattr(api_item, 'torrent_download', None) or api_item.link
|
||||||
|
return self
|
||||||
65
stream_fusion/utils/torr9/torr9_service.py
Normal file
65
stream_fusion/utils/torr9/torr9_service.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from stream_fusion.logging_config import logger
|
||||||
|
from stream_fusion.settings import settings
|
||||||
|
from stream_fusion.utils.torr9.torr9_api import Torr9API
|
||||||
|
from stream_fusion.utils.torr9.torr9_result import Torr9Result
|
||||||
|
from stream_fusion.utils.models.movie import Movie
|
||||||
|
from stream_fusion.utils.models.series import Series
|
||||||
|
|
||||||
|
|
||||||
|
class Torr9Service:
|
||||||
|
def __init__(self, config: dict, session: Optional[aiohttp.ClientSession] = None):
|
||||||
|
self.config = config
|
||||||
|
if settings.torr9_unique_account and settings.torr9_api_key:
|
||||||
|
api_key = settings.torr9_api_key
|
||||||
|
else:
|
||||||
|
api_key = config.get("torr9ApiKey") or settings.torr9_api_key
|
||||||
|
self.api = Torr9API(session=session, api_key=api_key)
|
||||||
|
|
||||||
|
async def search(self, media: Union[Movie, Series]) -> List[Torr9Result]:
|
||||||
|
try:
|
||||||
|
if isinstance(media, Movie):
|
||||||
|
return await self._search_movie(media)
|
||||||
|
elif isinstance(media, Series):
|
||||||
|
return await self._search_series(media)
|
||||||
|
else:
|
||||||
|
raise TypeError("Only Movie and Series are supported")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Torr9: Search error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _search_movie(self, media: Movie) -> List[Torr9Result]:
|
||||||
|
logger.info(f"Torr9: Searching movie: {media.titles[0]}")
|
||||||
|
imdb_id = media.id if media.id and media.id.startswith("tt") else None
|
||||||
|
if not imdb_id:
|
||||||
|
logger.debug(f"Torr9: No IMDB ID available, skipping search for '{media.titles[0]}'")
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw = await self.api.search_movie(imdb_id=imdb_id)
|
||||||
|
logger.info(f"Torr9: {len(raw)} raw results for movie '{media.titles[0]}'")
|
||||||
|
return self._build_results(raw, media)
|
||||||
|
|
||||||
|
async def _search_series(self, media: Series) -> List[Torr9Result]:
|
||||||
|
logger.info(f"Torr9: Searching series (global): {media.titles[0]}")
|
||||||
|
raw_id = media.id.split(":")[0] if media.id else None
|
||||||
|
imdb_id = raw_id if raw_id and raw_id.startswith("tt") else None
|
||||||
|
if not imdb_id:
|
||||||
|
logger.debug(f"Torr9: No IMDB ID available, skipping search for '{media.titles[0]}'")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Recherche globale (sans saison/épisode) pour tout stocker en Postgres
|
||||||
|
raw = await self.api.search_series(imdb_id=imdb_id)
|
||||||
|
logger.info(f"Torr9: {len(raw)} raw results for '{media.titles[0]}' (global)")
|
||||||
|
return self._build_results(raw, media)
|
||||||
|
|
||||||
|
def _build_results(self, raw_results, media) -> List[Torr9Result]:
|
||||||
|
results = []
|
||||||
|
for item in raw_results:
|
||||||
|
try:
|
||||||
|
result = Torr9Result().from_api_item(item, media)
|
||||||
|
results.append(result)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.debug(f"Torr9: Skipping item - {e}")
|
||||||
|
return results
|
||||||
1
stream_fusion/utils/torrent/__init__.py
Normal file
1
stream_fusion/utils/torrent/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
class TorrentItem:
|
class TorrentItem:
|
||||||
def __init__(self, raw_title, size, magnet, info_hash, link, seeders, languages, indexer,
|
def __init__(self, raw_title, size, magnet, info_hash, link, seeders, languages, indexer,
|
||||||
privacy, type=None, parsed_data=None):
|
privacy, type=None, parsed_data=None, torrent_download=None, tmdb_id=None):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
self.raw_title = raw_title # Raw title of the torrent
|
self.raw_title = raw_title # Raw title of the torrent
|
||||||
|
|
@ -22,15 +22,20 @@ class TorrentItem:
|
||||||
self.indexer = indexer # Indexer of the torrent
|
self.indexer = indexer # Indexer of the torrent
|
||||||
self.type = type # "series" or "movie"
|
self.type = type # "series" or "movie"
|
||||||
self.privacy = privacy # "public" or "private"
|
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.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.files = None # The files inside of the torrent. If it's None, it means that there is only one file inside of the torrent
|
||||||
self.torrent_download = None # The torrent jackett download url if its None, it means that there is only a magnet link provided by Jackett. It also means, that we cant do series file filtering before debrid.
|
self.torrent_download = torrent_download # The torrent jackett download url if its None, it means that there is only a magnet link provided by Jackett. It also means, that we cant do series file filtering before debrid.
|
||||||
self.trackers = [] # Trackers of the torrent
|
self.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.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.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
|
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
|
self.parsed_data: ParsedData = parsed_data # Ranked result
|
||||||
|
|
||||||
def to_debrid_stream_query(self, media: Media) -> dict:
|
def to_debrid_stream_query(self, media: Media) -> dict:
|
||||||
|
|
@ -40,12 +45,23 @@ class TorrentItem:
|
||||||
"file_index": self.file_index,
|
"file_index": self.file_index,
|
||||||
"season": media.season if isinstance(media, Series) else None,
|
"season": media.season if isinstance(media, Series) else None,
|
||||||
"episode": media.episode if isinstance(media, Series) else None,
|
"episode": media.episode if isinstance(media, Series) else None,
|
||||||
"torrent_download": quote(self.torrent_download) if self.torrent_download is not None else None,
|
"torrent_download": quote(self.torrent_download) if (self.torrent_download is not None and not self.availability) else None,
|
||||||
"service": self.availability if self.availability else "DL",
|
"service": self.availability if self.availability else "DL",
|
||||||
"privacy": self.privacy if self.privacy else "private",
|
"privacy": self.privacy if self.privacy else "private",
|
||||||
}
|
}
|
||||||
|
|
||||||
def to_dict(self):
|
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 {
|
return {
|
||||||
'raw_title': self.raw_title,
|
'raw_title': self.raw_title,
|
||||||
'size': self.size,
|
'size': self.size,
|
||||||
|
|
@ -57,6 +73,7 @@ class TorrentItem:
|
||||||
'indexer': self.indexer,
|
'indexer': self.indexer,
|
||||||
'type': self.type,
|
'type': self.type,
|
||||||
'privacy': self.privacy,
|
'privacy': self.privacy,
|
||||||
|
'tmdb_id': self.tmdb_id,
|
||||||
'file_name': self.file_name,
|
'file_name': self.file_name,
|
||||||
'files': self.files,
|
'files': self.files,
|
||||||
'torrent_download': self.torrent_download,
|
'torrent_download': self.torrent_download,
|
||||||
|
|
@ -64,6 +81,7 @@ class TorrentItem:
|
||||||
'file_index': self.file_index,
|
'file_index': self.file_index,
|
||||||
'full_index': self.full_index,
|
'full_index': self.full_index,
|
||||||
'availability': self.availability,
|
'availability': self.availability,
|
||||||
|
'parsed_data': parsed_data_dict,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -82,9 +100,10 @@ class TorrentItem:
|
||||||
languages=data['languages'],
|
languages=data['languages'],
|
||||||
indexer=data['indexer'],
|
indexer=data['indexer'],
|
||||||
privacy=data['privacy'],
|
privacy=data['privacy'],
|
||||||
type=data['type']
|
type=data['type'],
|
||||||
|
tmdb_id=data.get('tmdb_id')
|
||||||
)
|
)
|
||||||
|
|
||||||
instance.file_name = data['file_name']
|
instance.file_name = data['file_name']
|
||||||
instance.files = data['files']
|
instance.files = data['files']
|
||||||
instance.torrent_download = data['torrent_download']
|
instance.torrent_download = data['torrent_download']
|
||||||
|
|
@ -92,7 +111,28 @@ class TorrentItem:
|
||||||
instance.file_index = data['file_index']
|
instance.file_index = data['file_index']
|
||||||
instance.full_index = data['full_index']
|
instance.full_index = data['full_index']
|
||||||
instance.availability = data['availability']
|
instance.availability = data['availability']
|
||||||
|
|
||||||
instance.parsed_data = parse(instance.raw_title)
|
# Use cached parsed_data if available, otherwise parse raw_title
|
||||||
|
if data.get('parsed_data'):
|
||||||
|
try:
|
||||||
|
# Try to reconstruct ParsedData from dict
|
||||||
|
reconstructed = ParsedData(**data['parsed_data'])
|
||||||
|
logger.debug(f"TorrentItem.from_dict(): Reconstructed ParsedData: {reconstructed}, resolution={getattr(reconstructed, 'resolution', 'UNKNOWN')}")
|
||||||
|
# Validate that reconstruction was successful (not an empty/default object)
|
||||||
|
if reconstructed is not None:
|
||||||
|
instance.parsed_data = reconstructed
|
||||||
|
else:
|
||||||
|
logger.warning(f"TorrentItem.from_dict(): Reconstructed ParsedData is None, will re-parse")
|
||||||
|
instance.parsed_data = parse(instance.raw_title)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to reconstruct ParsedData from cache: {e}, will re-parse")
|
||||||
|
instance.parsed_data = parse(instance.raw_title)
|
||||||
|
else:
|
||||||
|
# Fallback to parsing if no cached parsed_data
|
||||||
|
logger.debug(f"TorrentItem.from_dict(): No parsed_data in cache dict, will parse")
|
||||||
|
instance.parsed_data = parse(instance.raw_title)
|
||||||
|
|
||||||
|
resolution = getattr(instance.parsed_data, 'resolution', 'UNKNOWN') if instance.parsed_data else 'NONE'
|
||||||
|
logger.debug(f"TorrentItem.from_dict(): '{instance.raw_title[:60]}' → resolution='{resolution}'")
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import os
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import pathlib
|
||||||
|
import json
|
||||||
|
|
||||||
import bencode
|
import bencodepy
|
||||||
import requests
|
import requests
|
||||||
from RTN import parse
|
from RTN import parse
|
||||||
|
from RTN.models import ParsedData
|
||||||
|
|
||||||
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
|
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
|
||||||
from stream_fusion.utils.jackett.jackett_result import JackettResult
|
from stream_fusion.utils.jackett.jackett_result import JackettResult
|
||||||
|
|
@ -19,11 +22,15 @@ from stream_fusion.logging_config import logger
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
class TorrentService:
|
class TorrentService:
|
||||||
|
TORRENT_CACHE_DIR = pathlib.Path("/var/cache/torrents")
|
||||||
|
|
||||||
def __init__(self, config, torrent_dao: TorrentItemDAO):
|
def __init__(self, config, torrent_dao: TorrentItemDAO):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.torrent_dao = torrent_dao
|
self.torrent_dao = torrent_dao
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.__session = requests.Session()
|
self.__session = requests.Session()
|
||||||
|
# Ensure cache directory exists
|
||||||
|
self.TORRENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __generate_unique_id(raw_title: str, indexer: str = "cached") -> str:
|
def __generate_unique_id(raw_title: str, indexer: str = "cached") -> str:
|
||||||
|
|
@ -36,6 +43,7 @@ class TorrentService:
|
||||||
try:
|
try:
|
||||||
cached_item = await self.torrent_dao.get_torrent_item_by_id(unique_id)
|
cached_item = await self.torrent_dao.get_torrent_item_by_id(unique_id)
|
||||||
if cached_item:
|
if cached_item:
|
||||||
|
# to_torrent_item() automatically reprout parsed_data from raw_title
|
||||||
return cached_item.to_torrent_item()
|
return cached_item.to_torrent_item()
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -44,9 +52,57 @@ class TorrentService:
|
||||||
|
|
||||||
async def cache_torrent(self, torrent_item: TorrentItem, id: str = None):
|
async def cache_torrent(self, torrent_item: TorrentItem, id: str = None):
|
||||||
unique_id = self.__generate_unique_id(torrent_item.raw_title, torrent_item.indexer)
|
unique_id = self.__generate_unique_id(torrent_item.raw_title, torrent_item.indexer)
|
||||||
await self.torrent_dao.create_torrent_item(torrent_item, unique_id)
|
# C411/Torr9 sans tmdb_id → orphelins, jamais retrouvés → on skip
|
||||||
|
if torrent_item.indexer in ['C411 - API', 'Torr9 - API'] and not torrent_item.tmdb_id:
|
||||||
|
self.logger.debug(f"TorrentService: Skipping {torrent_item.indexer} torrent without tmdb_id: {torrent_item.raw_title}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
existing = await self.torrent_dao.get_torrent_item_by_id(unique_id)
|
||||||
|
if existing:
|
||||||
|
# Already in DB, skip — don't overwrite tmdb_id or other fields
|
||||||
|
self.logger.debug(f"TorrentService: Torrent already cached, skipping: {unique_id}")
|
||||||
|
else:
|
||||||
|
await self.torrent_dao.create_torrent_item(torrent_item, unique_id)
|
||||||
|
self.logger.debug(f"TorrentService: Created new cached torrent: {unique_id}")
|
||||||
|
except Exception as e:
|
||||||
|
if "duplicate key value violates unique constraint" in str(e):
|
||||||
|
self.logger.debug(f"TorrentService: Race condition, torrent already exists: {unique_id}")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"TorrentService: Error caching torrent {unique_id}: {str(e)}")
|
||||||
|
|
||||||
async def convert_and_process(self, results: List[JackettResult | ZileanResult | YggflixResult | SharewoodResult]):
|
async def _update_cached_item(self, cached_item: TorrentItem, new_item: TorrentItem):
|
||||||
|
"""Update cached item with new data (tmdb_id, torrent_file_path) if available"""
|
||||||
|
try:
|
||||||
|
unique_id = self.__generate_unique_id(cached_item.raw_title, cached_item.indexer)
|
||||||
|
needs_update = False
|
||||||
|
|
||||||
|
# Update tmdb_id if the new item has one and cached doesn't
|
||||||
|
if new_item.tmdb_id and not cached_item.tmdb_id:
|
||||||
|
cached_item.tmdb_id = new_item.tmdb_id
|
||||||
|
needs_update = True
|
||||||
|
self.logger.debug(f"Updated tmdb_id for {unique_id}: {new_item.tmdb_id}")
|
||||||
|
|
||||||
|
# Update torrent_file_path if new item has one and cached doesn't (or is different)
|
||||||
|
if new_item.torrent_file_path and not cached_item.torrent_file_path:
|
||||||
|
cached_item.torrent_file_path = new_item.torrent_file_path
|
||||||
|
needs_update = True
|
||||||
|
self.logger.debug(f"Updated torrent_file_path for {unique_id}: {new_item.torrent_file_path}")
|
||||||
|
|
||||||
|
if needs_update:
|
||||||
|
await self.torrent_dao.update_torrent_item(unique_id, cached_item)
|
||||||
|
self.logger.info(f"Cached torrent updated: {unique_id}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error updating cached item: {e}")
|
||||||
|
|
||||||
|
async def convert_and_process(self, results: List[JackettResult | ZileanResult | YggflixResult | SharewoodResult], skip_yggflix_download: bool = False):
|
||||||
|
"""
|
||||||
|
Convert and process torrent results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: List of torrent results to process
|
||||||
|
skip_yggflix_download: If True, don't download .torrent files from Yggflix (just update metadata)
|
||||||
|
Used during search to avoid heavy downloads. Set to False for actual playback.
|
||||||
|
"""
|
||||||
torrent_items_result = []
|
torrent_items_result = []
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
|
|
@ -54,9 +110,25 @@ class TorrentService:
|
||||||
|
|
||||||
cached_item = await self.get_cached_torrent(torrent_item.raw_title, torrent_item.indexer)
|
cached_item = await self.get_cached_torrent(torrent_item.raw_title, torrent_item.indexer)
|
||||||
if cached_item:
|
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)
|
torrent_items_result.append(cached_item)
|
||||||
continue
|
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:"):
|
if torrent_item.link.startswith("magnet:"):
|
||||||
processed_torrent_item = self.__process_magnet(torrent_item)
|
processed_torrent_item = self.__process_magnet(torrent_item)
|
||||||
elif settings.sharewood_url and torrent_item.link.startswith(settings.sharewood_url):
|
elif settings.sharewood_url and torrent_item.link.startswith(settings.sharewood_url):
|
||||||
|
|
@ -137,12 +209,44 @@ class TorrentService:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def __process_torrent(self, result: TorrentItem, torrent_file):
|
def __process_torrent(self, result: TorrentItem, torrent_file):
|
||||||
metadata = bencode.bdecode(torrent_file)
|
try:
|
||||||
|
metadata = bencodepy.decode(torrent_file)
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
from bencodepy import Decoder
|
||||||
|
decoder = Decoder(encoding='latin-1')
|
||||||
|
metadata = decoder.decode(torrent_file)
|
||||||
|
except Exception as inner_e:
|
||||||
|
logger.error(f"Impossible de décoder le fichier torrent: {str(e)} puis {str(inner_e)}")
|
||||||
|
result.torrent_download = result.link
|
||||||
|
result.trackers = []
|
||||||
|
result.info_hash = ""
|
||||||
|
result.magnet = ""
|
||||||
|
return result
|
||||||
|
|
||||||
result.torrent_download = result.link
|
result.torrent_download = result.link
|
||||||
result.trackers = self.__get_trackers_from_torrent(metadata)
|
# Save .torrent file to disk instead of PostgreSQL
|
||||||
result.info_hash = self.__convert_torrent_to_hash(metadata["info"])
|
try:
|
||||||
result.magnet = self.__build_magnet(result.info_hash, metadata["info"]["name"], result.trackers)
|
# 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"])
|
||||||
|
result.magnet = self.__build_magnet(result.info_hash, metadata["info"]["name"], result.trackers)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors du traitement des métadonnées du torrent: {str(e)}")
|
||||||
|
result.trackers = []
|
||||||
|
result.info_hash = ""
|
||||||
|
result.magnet = ""
|
||||||
|
|
||||||
if "files" not in metadata["info"]:
|
if "files" not in metadata["info"]:
|
||||||
result.file_index = 1
|
result.file_index = 1
|
||||||
|
|
@ -151,7 +255,16 @@ class TorrentService:
|
||||||
result.files = metadata["info"]["files"]
|
result.files = metadata["info"]["files"]
|
||||||
|
|
||||||
if result.type == "series":
|
if result.type == "series":
|
||||||
file_details = self.__find_single_episode_file(result.files, result.parsed_data.seasons, result.parsed_data.episodes)
|
# Ensure we have parsed_data from raw_title
|
||||||
|
if not result.parsed_data:
|
||||||
|
result.parsed_data = parse(result.raw_title)
|
||||||
|
|
||||||
|
# Only try to find episode file if we have valid parsed_data
|
||||||
|
if result.parsed_data and isinstance(result.parsed_data, ParsedData):
|
||||||
|
file_details = self.__find_single_episode_file(result.files, result.parsed_data.seasons, result.parsed_data.episodes)
|
||||||
|
else:
|
||||||
|
file_details = None
|
||||||
|
self.logger.warning(f"No valid parsed_data for series torrent: {result.raw_title}")
|
||||||
|
|
||||||
if file_details is not None:
|
if file_details is not None:
|
||||||
self.logger.debug("File details")
|
self.logger.debug("File details")
|
||||||
|
|
@ -159,8 +272,9 @@ class TorrentService:
|
||||||
result.file_index = file_details["file_index"]
|
result.file_index = file_details["file_index"]
|
||||||
result.file_name = file_details["title"]
|
result.file_name = file_details["title"]
|
||||||
result.size = file_details["size"]
|
result.size = file_details["size"]
|
||||||
else:
|
|
||||||
result.full_index = self.__find_full_index(result.files)
|
# Always create full_index for series - AllDebrid needs all files to search for matching episode
|
||||||
|
result.full_index = self.__find_full_index(result.files)
|
||||||
|
|
||||||
if result.type == "movie":
|
if result.type == "movie":
|
||||||
result.file_index = self.__find_movie_file(result.files)
|
result.file_index = self.__find_movie_file(result.files)
|
||||||
|
|
@ -179,7 +293,7 @@ class TorrentService:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def __convert_torrent_to_hash(self, torrent_contents):
|
def __convert_torrent_to_hash(self, torrent_contents):
|
||||||
hashcontents = bencode.bencode(torrent_contents)
|
hashcontents = bencodepy.encode(torrent_contents)
|
||||||
hexHash = hashlib.sha1(hashcontents).hexdigest()
|
hexHash = hashlib.sha1(hashcontents).hexdigest()
|
||||||
return hexHash.lower()
|
return hexHash.lower()
|
||||||
|
|
||||||
|
|
@ -239,7 +353,7 @@ class TorrentService:
|
||||||
|
|
||||||
if season[0] in parsed_file.seasons and episode[0] in parsed_file.episodes:
|
if season[0] in parsed_file.seasons and episode[0] in parsed_file.episodes:
|
||||||
episode_files.append({
|
episode_files.append({
|
||||||
"file_index": file_index,
|
"file_index": None, # Let debrid service handle file selection
|
||||||
"title": file,
|
"title": file,
|
||||||
"size": files["length"]
|
"size": files["length"]
|
||||||
})
|
})
|
||||||
|
|
@ -247,7 +361,9 @@ class TorrentService:
|
||||||
# Doesn't that need to be indented?
|
# Doesn't that need to be indented?
|
||||||
file_index += 1
|
file_index += 1
|
||||||
|
|
||||||
return max(episode_files, key=lambda file: file["size"])
|
if episode_files:
|
||||||
|
return max(episode_files, key=lambda file: file["size"])
|
||||||
|
return None
|
||||||
|
|
||||||
def __find_full_index(self, file_structure):
|
def __find_full_index(self, file_structure):
|
||||||
self.logger.debug("Starting to build full index of video files")
|
self.logger.debug("Starting to build full index of video files")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from stream_fusion.utils.debrid.alldebrid import AllDebrid
|
||||||
from stream_fusion.utils.debrid.premiumize import Premiumize
|
from stream_fusion.utils.debrid.premiumize import Premiumize
|
||||||
from stream_fusion.utils.debrid.realdebrid import RealDebrid
|
from stream_fusion.utils.debrid.realdebrid import RealDebrid
|
||||||
from stream_fusion.utils.debrid.torbox import Torbox
|
from stream_fusion.utils.debrid.torbox import Torbox
|
||||||
|
from stream_fusion.utils.debrid.stremthru import StremThru
|
||||||
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
||||||
from stream_fusion.utils.cache.cache import cache_public
|
from stream_fusion.utils.cache.cache import cache_public
|
||||||
from stream_fusion.utils.general import season_episode_in_filename
|
from stream_fusion.utils.general import season_episode_in_filename
|
||||||
|
|
@ -24,6 +25,10 @@ class TorrentSmartContainer:
|
||||||
torrent_items
|
torrent_items
|
||||||
)
|
)
|
||||||
self.__media = media
|
self.__media = media
|
||||||
|
# Variable d'utilisation de StremThru maintenue pour la rétrocompatibilité
|
||||||
|
self.__using_stremthru = False
|
||||||
|
# Log pour indiquer que tous les torrents sont inclus
|
||||||
|
self.logger.info("TorrentSmartContainer: Including all torrents regardless of seeders count")
|
||||||
|
|
||||||
def get_unaviable_hashes(self):
|
def get_unaviable_hashes(self):
|
||||||
hashes = []
|
hashes = []
|
||||||
|
|
@ -70,30 +75,54 @@ class TorrentSmartContainer:
|
||||||
self.logger.trace(
|
self.logger.trace(
|
||||||
"TorrentSmartContainer: Item added to best matching (has file index)"
|
"TorrentSmartContainer: Item added to best matching (has file index)"
|
||||||
)
|
)
|
||||||
else:
|
elif self.__media.type == "series":
|
||||||
matching_file = self._find_matching_file(
|
if torrent_item.full_index:
|
||||||
torrent_item.full_index,
|
matching_file = self._find_matching_file(
|
||||||
self.__media.season,
|
torrent_item.full_index,
|
||||||
self.__media.episode,
|
self.__media.season,
|
||||||
)
|
self.__media.episode,
|
||||||
if matching_file:
|
)
|
||||||
torrent_item.file_index = matching_file["file_index"]
|
if matching_file:
|
||||||
torrent_item.file_name = matching_file["file_name"]
|
torrent_item.file_index = matching_file["file_index"]
|
||||||
torrent_item.size = matching_file["size"]
|
torrent_item.file_name = matching_file["file_name"]
|
||||||
|
torrent_item.size = matching_file["size"]
|
||||||
|
best_matching.append(torrent_item)
|
||||||
|
self.logger.trace(
|
||||||
|
f"TorrentSmartContainer: Item added to best matching (found matching file: {matching_file['file_name']})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.trace(
|
||||||
|
"TorrentSmartContainer: No matching file found in full_index, item not added to best matching"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No full_index available, add series torrent anyway - AllDebrid will extract files
|
||||||
best_matching.append(torrent_item)
|
best_matching.append(torrent_item)
|
||||||
self.logger.trace(
|
self.logger.trace(
|
||||||
f"TorrentSmartContainer: Item added to best matching (found matching file: {matching_file['file_name']})"
|
"TorrentSmartContainer: Item added to best matching (series without full_index, will be extracted by debrid)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.trace(
|
# For movies without file_index, still add them
|
||||||
"TorrentSmartContainer: No matching file found, item not added to best matching"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if not (( not torrent_item.availability or torrent_item.availability == "DL" ) and torrent_item.indexer == "DMM - API"):
|
|
||||||
best_matching.append(torrent_item)
|
best_matching.append(torrent_item)
|
||||||
self.logger.trace(
|
self.logger.trace(
|
||||||
"TorrentSmartContainer: Item added to best matching (magnet link)"
|
"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)
|
||||||
|
seeders_info = f"with {torrent_item.seeders} seeders" if torrent_item.seeders is not None else "with unknown seeders"
|
||||||
|
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(
|
self.logger.success(
|
||||||
f"TorrentSmartContainer: Found {len(best_matching)} best matching items"
|
f"TorrentSmartContainer: Found {len(best_matching)} best matching items"
|
||||||
)
|
)
|
||||||
|
|
@ -176,6 +205,30 @@ class TorrentSmartContainer:
|
||||||
self._update_availability_torbox(debrid_response, media)
|
self._update_availability_torbox(debrid_response, media)
|
||||||
elif debrid_type is Premiumize:
|
elif debrid_type is Premiumize:
|
||||||
self._update_availability_premiumize(debrid_response)
|
self._update_availability_premiumize(debrid_response)
|
||||||
|
elif debrid_type is StremThru or debrid_type.__name__ == "StremThru":
|
||||||
|
# Marquer que StremThru est utilisé
|
||||||
|
self.__using_stremthru = True
|
||||||
|
|
||||||
|
# Récupérer l'instance depuis le tableau de debrid_response
|
||||||
|
if debrid_response and isinstance(debrid_response[0], dict) and "store_name" in debrid_response[0]:
|
||||||
|
store_name = debrid_response[0]["store_name"]
|
||||||
|
else:
|
||||||
|
# Tenter de récupérer depuis le Logger
|
||||||
|
try:
|
||||||
|
log_entries = [line for line in self.logger.get_entries() if "StremThru: Vérification de" in line and "magnets sur StremThru-" in line]
|
||||||
|
if log_entries:
|
||||||
|
latest_log = log_entries[-1]
|
||||||
|
store_name = latest_log.split("StremThru-")[-1].strip()
|
||||||
|
else:
|
||||||
|
# Fallback sur les stores courants si on ne peut pas détecter
|
||||||
|
store_name = "torbox" if "TBToken" in str(debrid_response) else "alldebrid"
|
||||||
|
except:
|
||||||
|
# Fallback sur "torbox" s'il y a TB dans les logs
|
||||||
|
store_name = "torbox" if "TBToken" in str(debrid_response) else "alldebrid"
|
||||||
|
|
||||||
|
underlying_debrid = StremThru.get_underlying_debrid_code(store_name)
|
||||||
|
self.logger.debug(f"TorrentSmartContainer: StremThru utilise le store: {store_name}, code: {underlying_debrid}")
|
||||||
|
self._update_availability_stremthru(debrid_response, media, underlying_debrid)
|
||||||
else:
|
else:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"TorrentSmartContainer: Unsupported debrid type: {debrid_type.__name__}"
|
f"TorrentSmartContainer: Unsupported debrid type: {debrid_type.__name__}"
|
||||||
|
|
@ -302,7 +355,7 @@ class TorrentSmartContainer:
|
||||||
processed_files.append(
|
processed_files.append(
|
||||||
{
|
{
|
||||||
"file_index": index,
|
"file_index": index,
|
||||||
"title": file["name"],
|
"title": os.path.basename(file["name"]),
|
||||||
"size": file["size"],
|
"size": file["size"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -310,7 +363,7 @@ class TorrentSmartContainer:
|
||||||
processed_files.append(
|
processed_files.append(
|
||||||
{
|
{
|
||||||
"file_index": index,
|
"file_index": index,
|
||||||
"title": file["name"],
|
"title": os.path.basename(file["name"]),
|
||||||
"size": file["size"],
|
"size": file["size"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -357,63 +410,157 @@ class TorrentSmartContainer:
|
||||||
|
|
||||||
if (numeric_season in file_info.get("seasons", []) and
|
if (numeric_season in file_info.get("seasons", []) and
|
||||||
numeric_episode in file_info.get("episodes", [])):
|
numeric_episode in file_info.get("episodes", [])):
|
||||||
matching_files.append(file_info)
|
matched_file_info = {
|
||||||
|
"file_index": file_info.get("file_index", 0),
|
||||||
if matching_files:
|
"title": file_info.get("file_name", ""),
|
||||||
# Prendre le plus gros fichier parmi ceux qui correspondent
|
"size": file_info.get("size", 0),
|
||||||
best_match = max(matching_files, key=lambda x: x.get("size", 0))
|
}
|
||||||
file_info = {
|
self._update_file_details(item, [matched_file_info], debrid="PM")
|
||||||
"file_index": best_match.get("file_index", 0),
|
self.logger.debug(
|
||||||
"title": best_match.get("file_name", ""),
|
f"TorrentSmartContainer: Updated series file details for {item.raw_title}: {matched_file_info}"
|
||||||
"size": best_match.get("size", 0)
|
)
|
||||||
}
|
break
|
||||||
self._update_file_details(item, [file_info], debrid="PM")
|
elif item.type == "movie":
|
||||||
self.logger.debug(
|
# Process movie files
|
||||||
f"TorrentSmartContainer: Updated series file details from full_index for {item.raw_title}: {file_info}"
|
self.logger.debug(
|
||||||
)
|
f"TorrentSmartContainer: Processing movie files for {item.raw_title}"
|
||||||
else:
|
)
|
||||||
# Si aucun fichier ne correspond dans l'index, garder quand même le torrent
|
|
||||||
self.logger.debug(
|
# Vérifier si nous avons des informations sur le fichier dans le status
|
||||||
f"TorrentSmartContainer: No matching file found in full_index for {item.raw_title}, keeping torrent"
|
file_info = None
|
||||||
|
|
||||||
|
# Si nous avons des fichiers dans le status
|
||||||
|
if "files" in status:
|
||||||
|
cached_files = [
|
||||||
|
f for f in status["files"] if f.get("cached", False) is True
|
||||||
|
]
|
||||||
|
if cached_files:
|
||||||
|
# Find the largest cached file
|
||||||
|
largest_file = max(
|
||||||
|
cached_files, key=lambda f: f.get("size", 0)
|
||||||
)
|
)
|
||||||
file_info = {
|
file_info = {
|
||||||
"file_index": 0,
|
"file_index": largest_file.get("file_index", 0),
|
||||||
"title": status.get("filename", item.raw_title),
|
"title": largest_file.get("title", ""),
|
||||||
"size": int(status.get("filesize", 0))
|
"size": largest_file.get("size", 0),
|
||||||
}
|
}
|
||||||
self._update_file_details(item, [file_info], debrid="PM")
|
|
||||||
else:
|
# Si nous n'avons pas d'infos de fichiers mais un nom de fichier et une taille
|
||||||
# Si pas d'index complet, garder le torrent
|
if not file_info and "filename" in status and "filesize" in status:
|
||||||
file_info = {
|
file_info = {
|
||||||
"file_index": 0,
|
"file_index": 0,
|
||||||
"title": status.get("filename", item.raw_title),
|
"title": status.get("filename", ""),
|
||||||
"size": int(status.get("filesize", 0))
|
"size": int(status.get("filesize", 0)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Si nous avons des informations sur le fichier, mettre à jour
|
||||||
|
if file_info:
|
||||||
self._update_file_details(item, [file_info], debrid="PM")
|
self._update_file_details(item, [file_info], debrid="PM")
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"TorrentSmartContainer: No full_index available for {item.raw_title}, keeping torrent"
|
f"TorrentSmartContainer: Updated movie file details for {item.raw_title}: {file_info}"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Pour les films, utiliser les informations de base
|
|
||||||
file_info = {
|
|
||||||
"file_index": 0,
|
|
||||||
"title": status.get("filename") or item.raw_title,
|
|
||||||
"size": int(status.get("filesize", 0))
|
|
||||||
}
|
|
||||||
self._update_file_details(item, [file_info], debrid="PM")
|
|
||||||
self.logger.debug(
|
|
||||||
f"TorrentSmartContainer: Updated movie file details for {item.raw_title}: {file_info}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
f"TorrentSmartContainer: Updated availability for {item.raw_title}: {item.availability}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"TorrentSmartContainer: Premiumize availability update completed"
|
"TorrentSmartContainer: Premiumize availability update completed"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _update_file_details(self, torrent_item, files, debrid: str = "??"):
|
def _update_availability_stremthru(self, response, media, underlying_debrid="AD"):
|
||||||
|
self.logger.info(f"TorrentSmartContainer: Updating StremThru availability (via {underlying_debrid})")
|
||||||
|
for result in response:
|
||||||
|
hash_value = result.get("hash", "").lower()
|
||||||
|
|
||||||
|
# Utiliser le code debrid fourni par StremThru si disponible
|
||||||
|
result_debrid = result.get("debrid")
|
||||||
|
if result_debrid:
|
||||||
|
debrid_code = result_debrid
|
||||||
|
self.logger.debug(f"TorrentSmartContainer: Utilisation du code debrid spécifique: {debrid_code} pour {hash_value}")
|
||||||
|
else:
|
||||||
|
debrid_code = underlying_debrid
|
||||||
|
|
||||||
|
if hash_value in self.__itemsDict:
|
||||||
|
item = self.__itemsDict[hash_value]
|
||||||
|
item.availability = debrid_code # Utiliser le code du service spécifique à ce résultat
|
||||||
|
|
||||||
|
# Récupérer les fichiers du torrent
|
||||||
|
files = result.get("files", [])
|
||||||
|
|
||||||
|
# Détecter si c'est un pack de saison (plus de 5 fichiers vidéo)
|
||||||
|
video_files = [f for f in files if f.get("name", "").lower().endswith((".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"))]
|
||||||
|
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]}")
|
||||||
|
|
||||||
|
if item.type == "series":
|
||||||
|
# Version originale simple : chercher les fichiers qui correspondent à l'épisode
|
||||||
|
self.logger.debug(
|
||||||
|
f"TorrentSmartContainer: Processing series files for {item.raw_title}"
|
||||||
|
)
|
||||||
|
matching_files = []
|
||||||
|
clean_season = media.season.replace("S", "")
|
||||||
|
clean_episode = media.episode.replace("E", "")
|
||||||
|
numeric_season = int(clean_season)
|
||||||
|
numeric_episode = int(clean_episode)
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
file_name = file.get("name", "")
|
||||||
|
if season_episode_in_filename(file_name, numeric_season, numeric_episode):
|
||||||
|
file_info = {
|
||||||
|
"file_index": file.get("index", 0),
|
||||||
|
"title": file_name,
|
||||||
|
"size": file.get("size", 0),
|
||||||
|
}
|
||||||
|
matching_files.append(file_info)
|
||||||
|
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)
|
||||||
|
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 = {
|
||||||
|
"file_index": fallback_file.get("index", 0),
|
||||||
|
"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.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
|
||||||
|
item.availability = debrid_code
|
||||||
|
|
||||||
|
elif item.type == "movie":
|
||||||
|
self.logger.debug(
|
||||||
|
f"TorrentSmartContainer: Processing movie files for {item.raw_title}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if files:
|
||||||
|
file_infos = [
|
||||||
|
{
|
||||||
|
"file_index": file.get("index", 0),
|
||||||
|
"title": file.get("name", ""),
|
||||||
|
"size": file.get("size", 0),
|
||||||
|
}
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
self._update_file_details(item, file_infos, debrid=debrid_code)
|
||||||
|
self.logger.debug(
|
||||||
|
f"TorrentSmartContainer: Updated movie file details for {item.raw_title}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f"TorrentSmartContainer: Updated availability for {item.raw_title}: {item.availability}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"TorrentSmartContainer: StremThru availability update completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_file_details(self, torrent_item, files, debrid: str = "??", skip_file_name_for_series=False):
|
||||||
if not files:
|
if not files:
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"TorrentSmartContainer: No files to update for {torrent_item.raw_title}"
|
f"TorrentSmartContainer: No files to update for {torrent_item.raw_title}"
|
||||||
|
|
@ -421,8 +568,14 @@ class TorrentSmartContainer:
|
||||||
return
|
return
|
||||||
file = max(files, key=lambda file: file["size"])
|
file = max(files, key=lambda file: file["size"])
|
||||||
torrent_item.availability = debrid
|
torrent_item.availability = debrid
|
||||||
torrent_item.file_index = file["file_index"]
|
# Only update file_index if it wasn't set to None for intelligent selection
|
||||||
torrent_item.file_name = file["title"]
|
if torrent_item.file_index is not None:
|
||||||
|
torrent_item.file_index = file["file_index"]
|
||||||
|
# For series, assign file title to raw_title to avoid StremThru duplication
|
||||||
|
if torrent_item.type == "series":
|
||||||
|
torrent_item.raw_title = file["title"]
|
||||||
|
else:
|
||||||
|
torrent_item.file_name = file["title"]
|
||||||
torrent_item.size = file["size"]
|
torrent_item.size = file["size"]
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"TorrentSmartContainer: Updated file details for {torrent_item.raw_title}: {file['title']}"
|
f"TorrentSmartContainer: Updated file details for {torrent_item.raw_title}: {file['title']}"
|
||||||
|
|
@ -439,9 +592,17 @@ class TorrentSmartContainer:
|
||||||
self.logger.debug(f"Adding {item.info_hash} to items dict")
|
self.logger.debug(f"Adding {item.info_hash} to items dict")
|
||||||
items_dict[item.info_hash] = item
|
items_dict[item.info_hash] = item
|
||||||
else:
|
else:
|
||||||
self.logger.debug(
|
# Garder Yggtorrent si présent, sinon garder le premier
|
||||||
f"TorrentSmartContainer: Skipping duplicate info hash: {item.info_hash}"
|
existing_item = items_dict[item.info_hash]
|
||||||
)
|
if item.indexer == "Yggtorrent - API" and existing_item.indexer != "Yggtorrent - API":
|
||||||
|
self.logger.debug(
|
||||||
|
f"TorrentSmartContainer: Replacing {existing_item.indexer} with Yggtorrent for hash: {item.info_hash}"
|
||||||
|
)
|
||||||
|
items_dict[item.info_hash] = item
|
||||||
|
else:
|
||||||
|
self.logger.debug(
|
||||||
|
f"TorrentSmartContainer: Skipping duplicate info hash: {item.info_hash} (keeping {existing_item.indexer})"
|
||||||
|
)
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"TorrentSmartContainer: Built dictionary with {len(items_dict)} unique items"
|
f"TorrentSmartContainer: Built dictionary with {len(items_dict)} unique items"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,7 @@ from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
class YggflixAPI:
|
class YggflixAPI:
|
||||||
def __init__(
|
def __init__(self, pool_connections=10, pool_maxsize=50, max_retries=0, timeout=5):
|
||||||
self,
|
|
||||||
pool_connections=10,
|
|
||||||
pool_maxsize=10,
|
|
||||||
max_retries=3,
|
|
||||||
timeout=10,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize the YggflixAPI class.
|
|
||||||
|
|
||||||
This constructor sets up the API client with connection pooling and retry strategies.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pool_connections (int): Number of connection pools to cache. Defaults to 10.
|
|
||||||
pool_maxsize (int): Maximum number of connections to save in the pool. Defaults to 10.
|
|
||||||
max_retries (int): Maximum number of retries for failed requests. Defaults to 3.
|
|
||||||
timeout (int): Timeout for requests in seconds. Defaults to 10.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
The base URL for the API is set using the settings.yggflix_url value.
|
|
||||||
"""
|
|
||||||
self.base_url = f"{settings.yggflix_url}/api"
|
self.base_url = f"{settings.yggflix_url}/api"
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|
@ -39,39 +19,14 @@ class YggflixAPI:
|
||||||
status_forcelist=[429, 500, 502, 503, 504],
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
allowed_methods=["HEAD", "GET", "OPTIONS"],
|
allowed_methods=["HEAD", "GET", "OPTIONS"],
|
||||||
)
|
)
|
||||||
adapter = HTTPAdapter(
|
adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=retry_strategy)
|
||||||
pool_connections=pool_connections,
|
|
||||||
pool_maxsize=pool_maxsize,
|
|
||||||
max_retries=retry_strategy,
|
|
||||||
)
|
|
||||||
self.session.mount("http://", adapter)
|
self.session.mount("http://", adapter)
|
||||||
self.session.mount("https://", adapter)
|
self.session.mount("https://", adapter)
|
||||||
|
|
||||||
def _make_request(self, method, endpoint, params=None):
|
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}"
|
url = f"{self.base_url}{endpoint}"
|
||||||
try:
|
try:
|
||||||
response = self.session.request(
|
response = self.session.request(method, url, params=params, timeout=self.timeout)
|
||||||
method, url, params=params, timeout=self.timeout
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
|
|
@ -88,136 +43,38 @@ class YggflixAPI:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def search(self, query=""):
|
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})
|
return self._make_request("GET", "/search", params={"q": query})
|
||||||
|
|
||||||
def get_home(self):
|
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")
|
return self._make_request("GET", "/home")
|
||||||
|
|
||||||
def get_movie_detail(self, movie_id: int):
|
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}")
|
return self._make_request("GET", f"/movie/{movie_id}")
|
||||||
|
|
||||||
def get_movie_torrents(self, movie_id: int):
|
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")
|
return self._make_request("GET", f"/movie/{movie_id}/torrents")
|
||||||
|
|
||||||
def get_tvshow_detail(self, tvshow_id: int):
|
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}")
|
return self._make_request("GET", f"/tvshow/{tvshow_id}")
|
||||||
|
|
||||||
def get_tvshow_torrents(self, tvshow_id: int):
|
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")
|
return self._make_request("GET", f"/tvshow/{tvshow_id}/torrents")
|
||||||
|
|
||||||
def get_torrents(
|
def get_torrents(self, page: int = 1, q: str = "", category_id: Optional[int] = None, order_by: str = "uploaded_at") -> List[dict]:
|
||||||
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}
|
params = {"page": page, "q": q, "order_by": order_by}
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
params["category_id"] = category_id
|
params["category_id"] = category_id
|
||||||
|
|
||||||
return self._make_request("GET", "/torrents", params=params)
|
return self._make_request("GET", "/torrents", params=params)
|
||||||
|
|
||||||
def get_torrent_detail(self, torrent_id: int) -> dict:
|
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}")
|
return self._make_request("GET", f"/torrent/{torrent_id}")
|
||||||
|
|
||||||
def download_torrent(self, torrent_id: int, passkey: str) -> bytes:
|
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:
|
if len(passkey) != 32:
|
||||||
raise ValueError("Passkey must be exactly 32 characters long.")
|
raise ValueError("Passkey must be exactly 32 characters long.")
|
||||||
|
|
||||||
url = f"{self.base_url}/torrent/{torrent_id}/download"
|
url = f"{self.base_url}/torrent/{torrent_id}/download"
|
||||||
params = {"passkey": passkey}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.get(url, params=params, timeout=self.timeout)
|
response = self.session.get(url, params={"passkey": passkey}, timeout=self.timeout)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.content
|
return response.content
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
|
|
@ -228,10 +85,4 @@ class YggflixAPI:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def __del__(self):
|
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()
|
self.session.close()
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,35 @@ from stream_fusion.utils.detection import detect_languages
|
||||||
|
|
||||||
class YggflixResult:
|
class YggflixResult:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.raw_title = None # Raw title of the torrent
|
self.raw_title = None
|
||||||
self.size = None # Size of the torrent
|
self.size = None
|
||||||
self.link = None # Contruct a magnet link here
|
self.link = None
|
||||||
self.indexer = None # Indexer
|
self.indexer = None
|
||||||
self.seeders = None # Seeders count
|
self.seeders = None
|
||||||
self.magnet = None # Magnet url
|
self.magnet = None
|
||||||
self.info_hash = None # infoHash by Jackett
|
self.info_hash = None
|
||||||
self.privacy = None # public or private
|
self.privacy = None
|
||||||
self.from_cache = None
|
self.from_cache = None
|
||||||
|
self.languages = None
|
||||||
self.languages = None # Language of the torrent
|
self.type = None
|
||||||
self.type = None # series or movie
|
self.tmdb_id = None
|
||||||
|
self.parsed_data = None
|
||||||
self.parsed_data = None # Ranked result
|
|
||||||
|
|
||||||
def convert_to_torrent_item(self):
|
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(
|
return TorrentItem(
|
||||||
self.raw_title,
|
raw_title=self.raw_title,
|
||||||
self.size,
|
size=self.size,
|
||||||
self.magnet,
|
magnet=self.magnet,
|
||||||
self.info_hash.lower() if self.info_hash is not None else None,
|
info_hash=self.info_hash.lower() if self.info_hash is not None else None,
|
||||||
self.link,
|
link=self.link,
|
||||||
self.seeders,
|
seeders=self.seeders,
|
||||||
self.languages,
|
languages=self.languages,
|
||||||
self.indexer,
|
indexer=self.indexer,
|
||||||
self.privacy,
|
privacy=self.privacy,
|
||||||
self.type,
|
type=self.type,
|
||||||
self.parsed_data
|
parsed_data=parsed_data,
|
||||||
|
torrent_download=None,
|
||||||
|
tmdb_id=self.tmdb_id
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
from urllib.parse import quote
|
||||||
from RTN import parse
|
from RTN import parse
|
||||||
|
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
|
@ -6,35 +8,16 @@ from stream_fusion.utils.detection import detect_languages
|
||||||
from stream_fusion.utils.yggfilx.yggflix_result import YggflixResult
|
from stream_fusion.utils.yggfilx.yggflix_result import YggflixResult
|
||||||
from stream_fusion.utils.models.movie import Movie
|
from stream_fusion.utils.models.movie import Movie
|
||||||
from stream_fusion.utils.models.series import Series
|
from stream_fusion.utils.models.series import Series
|
||||||
from stream_fusion.settings import settings
|
|
||||||
from stream_fusion.utils.yggfilx.yggflix_api import YggflixAPI
|
from stream_fusion.utils.yggfilx.yggflix_api import YggflixAPI
|
||||||
|
|
||||||
|
|
||||||
class YggflixService:
|
class YggflixService:
|
||||||
"""Service for searching media on Yggflix."""
|
|
||||||
|
|
||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict):
|
||||||
self.yggflix = YggflixAPI()
|
self.yggflix = YggflixAPI()
|
||||||
self.has_tmdb = config.get("metadataProvider") == "tmdb"
|
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]:
|
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):
|
if isinstance(media, Movie):
|
||||||
results = self.__search_movie(media)
|
results = self.__search_movie(media)
|
||||||
elif isinstance(media, Series):
|
elif isinstance(media, Series):
|
||||||
|
|
@ -45,72 +28,92 @@ class YggflixService:
|
||||||
return self.__post_process_results(results, media)
|
return self.__post_process_results(results, media)
|
||||||
|
|
||||||
def __filter_out_no_seeders(self, results: List[dict]) -> List[dict]:
|
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]
|
||||||
return [result for result in results if result.get("seeders", 0) >= 5]
|
|
||||||
|
|
||||||
def __process_download_link(self, id: int) -> str:
|
def __build_magnet(self, info_hash: str, title: str) -> str:
|
||||||
"""Generate the download link for a given torrent."""
|
return f"magnet:?xt=urn:btih:{info_hash}&dn={quote(title)}"
|
||||||
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]:
|
def __search_movie(self, media: Movie) -> List[dict]:
|
||||||
"""Search for a movie on Yggflix."""
|
|
||||||
if not self.has_tmdb:
|
if not self.has_tmdb:
|
||||||
raise ValueError("Please use TMDB metadata provider for Yggflix")
|
raise ValueError("Please use TMDB metadata provider for Yggflix")
|
||||||
|
|
||||||
try:
|
logger.info(f"Searching Yggflix for movie: {media.titles[0]}")
|
||||||
logger.info(f"Searching Yggflix for movie: {media.titles[0]}")
|
return self.yggflix.get_movie_torrents(media.tmdb_id)
|
||||||
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]:
|
def __search_series(self, media: Series) -> List[dict]:
|
||||||
"""Search for a series on Yggflix."""
|
|
||||||
if not self.has_tmdb:
|
if not self.has_tmdb:
|
||||||
raise ValueError("Please use TMDB metadata provider for Yggflix")
|
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:
|
try:
|
||||||
logger.info(f"Searching Yggflix for series: {media.titles[0]}")
|
detail = self.yggflix.get_torrent_detail(torrent_id)
|
||||||
return self.yggflix.get_tvshow_torrents(int(media.tmdb_id))
|
return result, detail.get("hash")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.warning(f"Yggflix: failed to get detail for torrent {torrent_id}: {e}")
|
||||||
f"Error searching Yggflix for series: {media.titles[0]}", exc_info=True
|
return result, None
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def __post_process_results(
|
def __post_process_results(
|
||||||
self, results: List[dict], media: Union[Movie, Series]
|
self, results: List[dict], media: Union[Movie, Series]
|
||||||
) -> List[YggflixResult]:
|
) -> List[YggflixResult]:
|
||||||
"""Process raw search results and convert them to YggflixResult objects."""
|
|
||||||
if not results:
|
if not results:
|
||||||
logger.info(f"No results found on Yggflix for: {media.titles[0]}")
|
logger.info(f"No results found on Yggflix for: {media.titles[0]}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
results = self.__filter_out_no_seeders(results)
|
results = self.__filter_out_no_seeders(results)
|
||||||
logger.info(f"{len(results)} results found on Yggflix for: {media.titles[0]}")
|
if isinstance(media, Series):
|
||||||
|
results = self.__filter_series_results(results, media)
|
||||||
|
results = sorted(results, key=lambda r: r.get("seeders", 0), reverse=True)[:50]
|
||||||
|
logger.info(f"{len(results)} results to fetch from Yggflix for: {media.titles[0]}")
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for result in results:
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
item = YggflixResult()
|
futures = {executor.submit(self.__fetch_detail, r): r for r in results}
|
||||||
|
for future in as_completed(futures):
|
||||||
item.raw_title=result["title"]
|
result, info_hash = future.result()
|
||||||
item.size=result.get("size", 0)
|
if not info_hash:
|
||||||
item.link=(
|
continue
|
||||||
self.__process_download_link(result.get("id"))
|
item = YggflixResult()
|
||||||
if result.get("id")
|
item.raw_title = result["title"]
|
||||||
else None
|
item.size = result.get("size", 0)
|
||||||
)
|
item.info_hash = info_hash.lower()
|
||||||
item.indexer="Yggtorrent - API"
|
item.magnet = self.__build_magnet(info_hash, result["title"])
|
||||||
item.seeders=result.get("seeders", 0)
|
item.link = item.magnet
|
||||||
item.privacy="private"
|
item.indexer = "Yggtorrent - API"
|
||||||
item.languages=detect_languages(item.raw_title, default_language="fr")
|
item.seeders = result.get("seeders", 0)
|
||||||
item.type=media.type
|
item.privacy = "private"
|
||||||
item.parsed_data=parse(item.raw_title)
|
item.languages = detect_languages(item.raw_title, default_language="fr")
|
||||||
|
item.type = media.type
|
||||||
items.append(item)
|
item.parsed_data = parse(item.raw_title)
|
||||||
logger.trace(f"Yggflix result: {item}")
|
item.tmdb_id = media.tmdb_id
|
||||||
|
items.append(item)
|
||||||
|
logger.trace(f"Yggflix result: {item}")
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import requests
|
import asyncio
|
||||||
from typing import List, Optional, Tuple
|
import aiohttp
|
||||||
|
from typing import List, Optional, Tuple, Dict, Any
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
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.settings import settings
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
class DMMQueryRequest(BaseModel):
|
class DMMQueryRequest(BaseModel):
|
||||||
queryText: Optional[str] = None
|
queryText: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DMMImdbFile(BaseModel):
|
class DMMImdbFile(BaseModel):
|
||||||
imdbId: Optional[str] = None
|
imdbId: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
|
@ -16,6 +18,7 @@ class DMMImdbFile(BaseModel):
|
||||||
adult: Optional[bool] = None
|
adult: Optional[bool] = None
|
||||||
year: Optional[int] = None
|
year: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class DMMImdbSearchResult(BaseModel):
|
class DMMImdbSearchResult(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
imdbId: Optional[str] = None
|
imdbId: Optional[str] = None
|
||||||
|
|
@ -23,6 +26,7 @@ class DMMImdbSearchResult(BaseModel):
|
||||||
score: float = 0.0
|
score: float = 0.0
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DMMTorrentInfo(BaseModel):
|
class DMMTorrentInfo(BaseModel):
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
@ -75,45 +79,104 @@ class DMMTorrentInfo(BaseModel):
|
||||||
imdb_id: Optional[str] = None
|
imdb_id: Optional[str] = None
|
||||||
imdb: Optional[DMMImdbFile] = None
|
imdb: Optional[DMMImdbFile] = None
|
||||||
|
|
||||||
|
|
||||||
class ZileanAPI:
|
class ZileanAPI:
|
||||||
def __init__(
|
def __init__(self, session: Optional[aiohttp.ClientSession] = None):
|
||||||
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
|
self.base_url = settings.zilean_url
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
logger.error("Zilean API URL is not set in the environment variables.")
|
logger.error("Zilean API URL is not set in the environment variables.")
|
||||||
raise ValueError("Zilean API URL is not set in the environment variables.")
|
raise ValueError("Zilean API URL is not set in the environment variables.")
|
||||||
|
|
||||||
self.session = requests.Session()
|
self._external_session = session is not None
|
||||||
retry_strategy = Retry(
|
self._session = session
|
||||||
total=max_retries,
|
self._timeout = aiohttp.ClientTimeout(total=30)
|
||||||
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)
|
|
||||||
|
|
||||||
def _request(self, method: str, endpoint: str, **kwargs):
|
# 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)
|
||||||
|
return f"{method}:{endpoint}:{params_str}"
|
||||||
|
|
||||||
|
def _get_from_cache(self, cache_key: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Récupère les données du cache si elles existent et sont valides."""
|
||||||
|
if cache_key in self._cache:
|
||||||
|
cached_data = self._cache[cache_key]
|
||||||
|
if time.time() - cached_data["timestamp"] < self._cache_ttl:
|
||||||
|
return cached_data["data"]
|
||||||
|
# Nettoyer les entrées expirées
|
||||||
|
del self._cache[cache_key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _add_to_cache(self, cache_key: str, data: Any) -> None:
|
||||||
|
"""Ajoute des données au cache avec un timestamp."""
|
||||||
|
self._cache[cache_key] = {
|
||||||
|
"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:
|
||||||
|
"""
|
||||||
|
Effectue une requête HTTP async avec gestion de cache.
|
||||||
|
|
||||||
|
:param method: Méthode HTTP (GET, POST, etc.)
|
||||||
|
:param endpoint: Point d'accès API
|
||||||
|
:param cache: Activer/désactiver le cache pour cette requête
|
||||||
|
:param kwargs: Arguments supplémentaires pour la requête
|
||||||
|
:return: Données JSON de la réponse
|
||||||
|
"""
|
||||||
url = f"{self.base_url}{endpoint}"
|
url = f"{self.base_url}{endpoint}"
|
||||||
headers = kwargs.pop("headers", {})
|
headers = kwargs.pop("headers", {})
|
||||||
headers.update(
|
headers.update(
|
||||||
{"accept": "application/json", "Content-Type": "application/json"}
|
{"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)
|
||||||
|
cached_data = self._get_from_cache(cache_key)
|
||||||
|
if cached_data:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.session.request(method, url, headers=headers, **kwargs)
|
async with session.request(method, url, headers=headers, **kwargs) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response
|
data = await response.json()
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logger.error(f"Erreur lors de la requête API : {e}")
|
# Mettre en cache les résultats pour les requêtes GET si activé
|
||||||
|
if cache and method.upper() == "GET":
|
||||||
|
try:
|
||||||
|
cache_key = self._get_cache_key(method, endpoint, **params)
|
||||||
|
self._add_to_cache(cache_key, data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Impossible de mettre en cache la réponse: {e}")
|
||||||
|
|
||||||
|
return data
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.error(f"Erreur lors de la requête API Zilean: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _convert_to_dmm_torrent_info(self, entry: dict) -> DMMTorrentInfo:
|
def _convert_to_dmm_torrent_info(self, entry: dict) -> DMMTorrentInfo:
|
||||||
|
|
@ -124,11 +187,11 @@ class ZileanAPI:
|
||||||
entry['imdb'] = DMMImdbFile(**entry['imdb'])
|
entry['imdb'] = DMMImdbFile(**entry['imdb'])
|
||||||
return DMMTorrentInfo(**entry)
|
return DMMTorrentInfo(**entry)
|
||||||
|
|
||||||
def dmm_search(self, query: DMMQueryRequest) -> List[DMMTorrentInfo]:
|
async def dmm_search(self, query: DMMQueryRequest) -> List[DMMTorrentInfo]:
|
||||||
response = self._request("POST", "/dmm/search", json=query.dict())
|
data = await self._request("POST", "/dmm/search", json=query.dict())
|
||||||
return [self._convert_to_dmm_torrent_info(entry) for entry in response.json()]
|
return [self._convert_to_dmm_torrent_info(entry) for entry in data]
|
||||||
|
|
||||||
def dmm_filtered(
|
async def dmm_filtered(
|
||||||
self,
|
self,
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
season: Optional[int] = None,
|
season: Optional[int] = None,
|
||||||
|
|
@ -148,23 +211,27 @@ class ZileanAPI:
|
||||||
"ImdbId": imdb_id,
|
"ImdbId": imdb_id,
|
||||||
}
|
}
|
||||||
params = {k: v for k, v in params.items() if v is not None}
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
response = self._request("GET", "/dmm/filtered", params=params)
|
data = await self._request("GET", "/dmm/filtered", params=params)
|
||||||
return [self._convert_to_dmm_torrent_info(entry) for entry in response.json()]
|
return [self._convert_to_dmm_torrent_info(entry) for entry in data]
|
||||||
|
|
||||||
def dmm_on_demand_scrape(self) -> None:
|
async def dmm_on_demand_scrape(self) -> None:
|
||||||
self._request("GET", "/dmm/on-demand-scrape")
|
await self._request("GET", "/dmm/on-demand-scrape", cache=False)
|
||||||
|
|
||||||
def healthchecks_ping(self) -> str:
|
async def healthchecks_ping(self) -> str:
|
||||||
response = self._request("GET", "/healthchecks/ping")
|
session = await self._get_session()
|
||||||
return response.text
|
url = f"{self.base_url}/healthchecks/ping"
|
||||||
|
async with session.get(url) as response:
|
||||||
|
return await response.text()
|
||||||
|
|
||||||
def imdb_search(
|
async def imdb_search(
|
||||||
self, query: Optional[str] = None, year: Optional[int] = None, category: Optional[str] = None
|
self, query: Optional[str] = None, year: Optional[int] = None, category: Optional[str] = None
|
||||||
) -> List[DMMImdbSearchResult]:
|
) -> List[DMMImdbSearchResult]:
|
||||||
params = {"Query": query, "Year": year, "Category": category}
|
params = {"Query": query, "Year": year, "Category": category}
|
||||||
params = {k: v for k, v in params.items() if v is not None}
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
response = self._request("POST", "/imdb/search", params=params)
|
data = await self._request("POST", "/imdb/search", params=params)
|
||||||
return [DMMImdbSearchResult(**file) for file in response.json()]
|
return [DMMImdbSearchResult(**file) for file in data]
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
self.session.close()
|
# Note: Ne pas fermer la session dans __del__ car c'est async
|
||||||
|
# Utiliser close() explicitement ou un context manager
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import re
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
import aiohttp
|
||||||
from typing import List, Union
|
from typing import List, Union, Dict, Tuple, Optional
|
||||||
|
import time
|
||||||
|
|
||||||
from stream_fusion.logging_config import logger
|
from stream_fusion.logging_config import logger
|
||||||
from stream_fusion.utils.models.movie import Movie
|
from stream_fusion.utils.models.movie import Movie
|
||||||
|
|
@ -8,20 +9,63 @@ from stream_fusion.utils.models.series import Series
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
from stream_fusion.utils.zilean.zilean_api import ZileanAPI, DMMQueryRequest, DMMTorrentInfo
|
from stream_fusion.utils.zilean.zilean_api import ZileanAPI, DMMQueryRequest, DMMTorrentInfo
|
||||||
|
|
||||||
|
|
||||||
class ZileanService:
|
class ZileanService:
|
||||||
def __init__(self, config):
|
def __init__(self, config, session: Optional[aiohttp.ClientSession] = None):
|
||||||
self.zilean_api = ZileanAPI()
|
self.zilean_api = ZileanAPI(session=session)
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.max_workers = settings.zilean_max_workers
|
self.max_workers = settings.zilean_max_workers
|
||||||
|
self._search_cache: Dict[str, Tuple[List[DMMTorrentInfo], float]] = {}
|
||||||
|
self._cache_ttl = 3600 # 1 heure en secondes
|
||||||
|
|
||||||
def search(self, media: Union[Movie, Series]) -> List[DMMTorrentInfo]:
|
async def search(self, media: Union[Movie, Series]) -> List[DMMTorrentInfo]:
|
||||||
|
# Vérifier si nous avons déjà des résultats en cache pour ce média
|
||||||
|
cache_key = self._get_cache_key(media)
|
||||||
|
cached_results = self._get_from_cache(cache_key)
|
||||||
|
if cached_results:
|
||||||
|
return cached_results
|
||||||
|
|
||||||
|
# Sinon, effectuer la recherche
|
||||||
if isinstance(media, Movie):
|
if isinstance(media, Movie):
|
||||||
return self.__search_movie(media)
|
results = await self.__search_movie(media)
|
||||||
elif isinstance(media, Series):
|
elif isinstance(media, Series):
|
||||||
return self.__search_series(media)
|
results = await self.__search_series(media)
|
||||||
else:
|
else:
|
||||||
raise TypeError("Only Movie and Series are allowed as media!")
|
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
|
||||||
|
|
||||||
|
def _get_cache_key(self, media: Union[Movie, Series]) -> str:
|
||||||
|
"""Génère une clé de cache unique pour un média."""
|
||||||
|
if isinstance(media, Movie):
|
||||||
|
return f"movie:{media.id}:{media.titles[0] if media.titles else ''}"
|
||||||
|
elif isinstance(media, Series):
|
||||||
|
season = getattr(media, 'season', '')
|
||||||
|
episode = getattr(media, 'episode', '')
|
||||||
|
return f"series:{media.id}:{media.titles[0] if media.titles else ''}:{season}:{episode}"
|
||||||
|
|
||||||
|
def _get_from_cache(self, cache_key: str) -> Optional[List[DMMTorrentInfo]]:
|
||||||
|
"""Récupère les résultats du cache s'ils existent et sont valides."""
|
||||||
|
if cache_key in self._search_cache:
|
||||||
|
results, timestamp = self._search_cache[cache_key]
|
||||||
|
if time.time() - timestamp < self._cache_ttl:
|
||||||
|
return results
|
||||||
|
# Nettoyer les entrées expirées
|
||||||
|
del self._search_cache[cache_key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
oldest_key = min(self._search_cache.keys(), key=lambda k: self._search_cache[k][1])
|
||||||
|
del self._search_cache[oldest_key]
|
||||||
|
|
||||||
def __deduplicate_api_results(self, api_results: List[DMMTorrentInfo]) -> List[DMMTorrentInfo]:
|
def __deduplicate_api_results(self, api_results: List[DMMTorrentInfo]) -> List[DMMTorrentInfo]:
|
||||||
unique_results = set()
|
unique_results = set()
|
||||||
deduplicated_results = []
|
deduplicated_results = []
|
||||||
|
|
@ -40,62 +84,96 @@ class ZileanService:
|
||||||
seen = set()
|
seen = set()
|
||||||
return [title for title in titles if not (title.lower() in seen or seen.add(title.lower()))]
|
return [title for title in titles if not (title.lower() in seen or seen.add(title.lower()))]
|
||||||
|
|
||||||
def __search_movie(self, movie: Movie) -> List[DMMTorrentInfo]:
|
async def __search_movie(self, movie: Movie) -> List[DMMTorrentInfo]:
|
||||||
unique_titles = self.__remove_duplicate_titles(movie.titles)
|
unique_titles = self.__remove_duplicate_titles(movie.titles)
|
||||||
keyword_results = self.__threaded_search_movie(unique_titles)
|
|
||||||
|
# Recherche par IMDb ID d'abord (souvent plus précise)
|
||||||
# Search by IMDb ID
|
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
|
||||||
# Combine and deduplicate results
|
if len(imdb_results) >= 10:
|
||||||
all_results = keyword_results + imdb_results
|
return imdb_results
|
||||||
|
|
||||||
|
# Sinon, compléter avec des recherches par titre en parallèle
|
||||||
|
keyword_results = await self.__parallel_search_movie(unique_titles)
|
||||||
|
|
||||||
|
# Combiner et dédupliquer les résultats
|
||||||
|
all_results = imdb_results + keyword_results
|
||||||
return self.__deduplicate_api_results(all_results)
|
return self.__deduplicate_api_results(all_results)
|
||||||
|
|
||||||
def __search_series(self, series: Series) -> List[DMMTorrentInfo]:
|
async def __search_series(self, series: Series) -> List[DMMTorrentInfo]:
|
||||||
unique_titles = self.__remove_duplicate_titles(series.titles)
|
unique_titles = self.__remove_duplicate_titles(series.titles)
|
||||||
keyword_results = self.__threaded_search_series(unique_titles, series)
|
|
||||||
|
# Recherche par IMDb ID d'abord (souvent plus précise)
|
||||||
# Search by IMDb ID
|
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
|
||||||
# Combine and deduplicate results
|
if len(imdb_results) >= 10:
|
||||||
all_results = keyword_results + imdb_results
|
return imdb_results
|
||||||
|
|
||||||
|
# Sinon, compléter avec des recherches par titre en parallèle
|
||||||
|
keyword_results = await self.__parallel_search_series(unique_titles, series)
|
||||||
|
|
||||||
|
# Combiner et dédupliquer les résultats
|
||||||
|
all_results = imdb_results + keyword_results
|
||||||
return self.__deduplicate_api_results(all_results)
|
return self.__deduplicate_api_results(all_results)
|
||||||
|
|
||||||
def __threaded_search_movie(self, search_texts: List[str]) -> List[DMMTorrentInfo]:
|
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)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
for result in results_lists:
|
||||||
future_to_text = {executor.submit(self.__make_movie_request, text): text for text in search_texts}
|
if isinstance(result, Exception):
|
||||||
for future in as_completed(future_to_text):
|
self.logger.exception(f"Error in parallel movie search: {result}")
|
||||||
results.extend(future.result())
|
elif result:
|
||||||
|
results.extend(result)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def __threaded_search_series(self, search_texts: List[str], series: Series) -> List[DMMTorrentInfo]:
|
async def __parallel_search_series(self, search_texts: List[str], series: Series) -> List[DMMTorrentInfo]:
|
||||||
|
"""Recherche parallèle avec asyncio.gather()."""
|
||||||
|
if not search_texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Limite le nombre de titres à rechercher pour éviter trop de requêtes
|
||||||
|
search_texts = search_texts[:3]
|
||||||
|
|
||||||
|
tasks = [self.__make_series_request(text, series) for text in search_texts]
|
||||||
|
results_lists = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
for result in results_lists:
|
||||||
future_to_text = {executor.submit(self.__make_series_request, text, series): text for text in search_texts}
|
if isinstance(result, Exception):
|
||||||
for future in as_completed(future_to_text):
|
self.logger.exception(f"Error in parallel series search: {result}")
|
||||||
results.extend(future.result())
|
elif result:
|
||||||
|
results.extend(result)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def __make_movie_request(self, query_text: str) -> List[DMMTorrentInfo]:
|
async def __make_movie_request(self, query_text: str) -> List[DMMTorrentInfo]:
|
||||||
try:
|
try:
|
||||||
return self.zilean_api.dmm_search(DMMQueryRequest(queryText=query_text))
|
return await self.zilean_api.dmm_search(DMMQueryRequest(queryText=query_text))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(f"An exception occurred while searching for movie '{query_text}' on Zilean: {str(e)}")
|
self.logger.exception(f"An exception occurred while searching for movie '{query_text}' on Zilean: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def __make_series_request(self, query_text: str, series: Series) -> List[DMMTorrentInfo]:
|
async def __make_series_request(self, query_text: str, series: Series) -> List[DMMTorrentInfo]:
|
||||||
try:
|
try:
|
||||||
season = getattr(series, 'season', None)
|
season = getattr(series, 'season', None)
|
||||||
episode = getattr(series, 'episode', None)
|
episode = getattr(series, 'episode', None)
|
||||||
|
|
||||||
if season is not None:
|
if season is not None:
|
||||||
season = season.lstrip('S') if isinstance(season, str) else season
|
season = season.lstrip('S') if isinstance(season, str) else season
|
||||||
if episode is not None:
|
if episode is not None:
|
||||||
episode = episode.lstrip('E') if isinstance(episode, str) else episode
|
episode = episode.lstrip('E') if isinstance(episode, str) else episode
|
||||||
|
|
||||||
return self.zilean_api.dmm_filtered(
|
return await self.zilean_api.dmm_filtered(
|
||||||
query=query_text,
|
query=query_text,
|
||||||
season=season,
|
season=season,
|
||||||
episode=episode
|
episode=episode
|
||||||
|
|
@ -104,9 +182,13 @@ class ZileanService:
|
||||||
self.logger.exception(f"An exception occurred while searching for series '{query_text}' on Zilean: {str(e)}")
|
self.logger.exception(f"An exception occurred while searching for series '{query_text}' on Zilean: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def __search_by_imdb_id(self, imdb_id: str) -> List[DMMTorrentInfo]:
|
async def __search_by_imdb_id(self, imdb_id: str) -> List[DMMTorrentInfo]:
|
||||||
try:
|
try:
|
||||||
return self.zilean_api.dmm_filtered(imdb_id=imdb_id)
|
return await self.zilean_api.dmm_filtered(imdb_id=imdb_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(f"An exception occurred while searching for IMDb ID '{imdb_id}' on Zilean: {str(e)}")
|
self.logger.exception(f"An exception occurred while searching for IMDb ID '{imdb_id}' on Zilean: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Ferme les ressources."""
|
||||||
|
await self.zilean_api.close()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from stream_fusion.services.postgresql.schemas.apikey_schemas import APIKeyCreate
|
from stream_fusion.services.postgresql.schemas.apikey_schemas import APIKeyCreate, APIKeyUpdate
|
||||||
from stream_fusion.utils.security.security_secret import SecretManager
|
from stream_fusion.utils.security.security_secret import SecretManager
|
||||||
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
||||||
from stream_fusion.web.api.auth.schemas import UsageLogs, UsageLog
|
from stream_fusion.web.api.auth.schemas import UsageLogs, UsageLog
|
||||||
|
|
@ -129,6 +129,7 @@ async def list_api_keys(
|
||||||
),
|
),
|
||||||
total_queries=key.total_queries,
|
total_queries=key.total_queries,
|
||||||
name=key.name if key.name else "JohnDoe",
|
name=key.name if key.name else "JohnDoe",
|
||||||
|
proxied_links=key.proxied_links,
|
||||||
)
|
)
|
||||||
for key in usage_stats
|
for key in usage_stats
|
||||||
]
|
]
|
||||||
|
|
@ -156,13 +157,14 @@ async def create_api_key(
|
||||||
authenticated: bool = Depends(session_based_security),
|
authenticated: bool = Depends(session_based_security),
|
||||||
name: str = Form(None),
|
name: str = Form(None),
|
||||||
never_expires: bool = Form(False),
|
never_expires: bool = Form(False),
|
||||||
|
proxied_links: bool = Form(False),
|
||||||
apikey_dao: APIKeyDAO = Depends(),
|
apikey_dao: APIKeyDAO = Depends(),
|
||||||
):
|
):
|
||||||
if isinstance(authenticated, RedirectResponse):
|
if isinstance(authenticated, RedirectResponse):
|
||||||
return authenticated
|
return authenticated
|
||||||
|
|
||||||
logger.info(f"Creating new API key. Name: {name}, Never expires: {never_expires}")
|
logger.info(f"Creating new API key. Name: {name}, Never expires: {never_expires}, Proxied links: {proxied_links}")
|
||||||
key = APIKeyCreate(name=name, never_expire=never_expires)
|
key = APIKeyCreate(name=name, never_expire=never_expires, proxied_links=proxied_links)
|
||||||
new_key = await apikey_dao.create_key(key)
|
new_key = await apikey_dao.create_key(key)
|
||||||
logger.info(f"New API key created: {new_key.api_key}")
|
logger.info(f"New API key created: {new_key.api_key}")
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
|
|
@ -247,6 +249,41 @@ async def delete_api_key(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/toggle-proxied-links")
|
||||||
|
async def toggle_proxied_links(
|
||||||
|
request: Request,
|
||||||
|
authenticated: bool = Depends(session_based_security),
|
||||||
|
api_key: str = Form(...),
|
||||||
|
apikey_dao: APIKeyDAO = Depends(),
|
||||||
|
):
|
||||||
|
if isinstance(authenticated, RedirectResponse):
|
||||||
|
return authenticated
|
||||||
|
|
||||||
|
api_key_uuid = ensure_uuid(api_key)
|
||||||
|
logger.info(f"Toggling proxied links for API key: {api_key_uuid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Récupérer la clé API
|
||||||
|
key_info = await apikey_dao.get_key_by_uuid(api_key_uuid)
|
||||||
|
if not key_info:
|
||||||
|
logger.warning(f"API key not found for toggling proxied links: {api_key_uuid}")
|
||||||
|
return RedirectResponse(
|
||||||
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mettre à jour avec la valeur inverse
|
||||||
|
current_value = getattr(key_info, 'proxied_links', False)
|
||||||
|
update_data = APIKeyUpdate(proxied_links=not current_value)
|
||||||
|
|
||||||
|
await apikey_dao.update_key(api_key_uuid, update_data)
|
||||||
|
logger.info(f"Proxied links toggled to {not current_value} for API key: {api_key_uuid}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error toggling proxied links for API key {api_key_uuid}: {str(e)}")
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
async def logout(request: Request, redis_client=get_redis_dependency()):
|
async def logout(request: Request, redis_client=get_redis_dependency()):
|
||||||
logger.info("User logging out")
|
logger.info("User logging out")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class UsageLog(BaseModel):
|
||||||
expiration_date: datetime | str | None
|
expiration_date: datetime | str | None
|
||||||
latest_query_date: Optional[datetime] | str | None
|
latest_query_date: Optional[datetime] | str | None
|
||||||
total_queries: int
|
total_queries: int
|
||||||
|
proxied_links: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UsageLogs(BaseModel):
|
class UsageLogs(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from stream_fusion.logging_config import configure_logging
|
||||||
from stream_fusion.services.postgresql.base import Base
|
from stream_fusion.services.postgresql.base import Base
|
||||||
from stream_fusion.services.postgresql.models import load_all_models
|
from stream_fusion.services.postgresql.models import load_all_models
|
||||||
from stream_fusion.settings import settings
|
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
|
def _setup_db(app: FastAPI) -> None: # pragma: no cover
|
||||||
|
|
@ -25,7 +24,7 @@ def _setup_db(app: FastAPI) -> None: # pragma: no cover
|
||||||
|
|
||||||
:param app: fastAPI application.
|
:param app: fastAPI application.
|
||||||
"""
|
"""
|
||||||
engine = create_async_engine(str(settings.pg_url), echo=settings.pg_echo)
|
engine = create_async_engine(str(settings.pg_url), echo=settings.pg_echo, pool_size=settings.pg_pool_size, max_overflow=settings.pg_max_overflow)
|
||||||
session_factory = async_sessionmaker(
|
session_factory = async_sessionmaker(
|
||||||
engine,
|
engine,
|
||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
|
|
@ -33,8 +32,6 @@ def _setup_db(app: FastAPI) -> None: # pragma: no cover
|
||||||
app.state.db_engine = engine
|
app.state.db_engine = engine
|
||||||
app.state.db_session_factory = session_factory
|
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
|
@asynccontextmanager
|
||||||
|
|
@ -47,17 +44,8 @@ async def lifespan_setup(
|
||||||
:param app: the FastAPI application.
|
:param app: the FastAPI application.
|
||||||
:yield: None
|
:yield: None
|
||||||
"""
|
"""
|
||||||
# Startup actions
|
|
||||||
app.middleware_stack = None
|
app.middleware_stack = None
|
||||||
_setup_db(app)
|
_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()
|
app.middleware_stack = app.build_middleware_stack()
|
||||||
|
|
||||||
if settings.playback_proxy and settings.proxy_url:
|
if settings.playback_proxy and settings.proxy_url:
|
||||||
|
|
@ -72,8 +60,16 @@ async def lifespan_setup(
|
||||||
timeout = aiohttp.ClientTimeout(total=settings.aiohttp_timeout)
|
timeout = aiohttp.ClientTimeout(total=settings.aiohttp_timeout)
|
||||||
app.state.http_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
|
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(
|
app.state.redis_pool = ConnectionPool(
|
||||||
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db, max_connections=50
|
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db, max_connections=200
|
||||||
)
|
)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
@ -81,6 +77,8 @@ async def lifespan_setup(
|
||||||
# Shutdown actions
|
# Shutdown actions
|
||||||
if app.state.http_session:
|
if app.state.http_session:
|
||||||
await app.state.http_session.close()
|
await app.state.http_session.close()
|
||||||
|
if app.state.debrid_session:
|
||||||
|
await app.state.debrid_session.close()
|
||||||
if app.state.redis_pool:
|
if app.state.redis_pool:
|
||||||
app.state.redis_pool.disconnect()
|
app.state.redis_pool.disconnect()
|
||||||
await app.state.db_engine.dispose()
|
await app.state.db_engine.dispose()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
|
|
@ -6,8 +7,6 @@ import asyncio
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from redis.exceptions import LockError
|
from redis.exceptions import LockError
|
||||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
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 starlette.background import BackgroundTask
|
||||||
|
|
||||||
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
||||||
|
|
@ -21,6 +20,8 @@ from stream_fusion.utils.debrid.get_debrid_service import (
|
||||||
)
|
)
|
||||||
from stream_fusion.utils.debrid.realdebrid import RealDebrid
|
from stream_fusion.utils.debrid.realdebrid import RealDebrid
|
||||||
from stream_fusion.utils.debrid.torbox import Torbox
|
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.parse_config import parse_config
|
||||||
from stream_fusion.utils.string_encoding import decodeb64
|
from stream_fusion.utils.string_encoding import decodeb64
|
||||||
from stream_fusion.utils.security import check_api_key
|
from stream_fusion.utils.security import check_api_key
|
||||||
|
|
@ -34,13 +35,32 @@ router = APIRouter()
|
||||||
redis_client = redis.Redis(
|
redis_client = redis.Redis(
|
||||||
host=settings.redis_host, port=settings.redis_port, db=settings.redis_db
|
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"
|
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:
|
class ProxyStreamer:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -112,30 +132,75 @@ class ProxyStreamer:
|
||||||
|
|
||||||
|
|
||||||
async def handle_download(
|
async def handle_download(
|
||||||
query: dict, config: dict, ip: str, redis_cache: RedisCache
|
query: dict, config: dict, ip: str, redis_cache: RedisCache, debrid_session=None
|
||||||
) -> str:
|
) -> str:
|
||||||
api_key = config.get("apiKey")
|
api_key = config.get("apiKey")
|
||||||
cache_key = f"download:{api_key}:{json.dumps(query)}_{ip}"
|
cache_key = f"download:{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")
|
||||||
|
|
||||||
|
direct_link_cache_key = f"direct_link:{api_key}:{json.dumps(query)}_{ip}"
|
||||||
|
cached_direct_link = await redis_cache.get(direct_link_cache_key)
|
||||||
|
|
||||||
|
if cached_direct_link:
|
||||||
|
logger.info("Playback: Direct link found in cache, returning immediately")
|
||||||
|
return cached_direct_link
|
||||||
|
|
||||||
|
debrid_service = get_download_service(config, debrid_session)
|
||||||
|
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)
|
||||||
|
logger.info("Playback: Direct link generated and cached")
|
||||||
|
return direct_link
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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)
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)}")
|
||||||
|
|
||||||
# Check if a download is already in progress
|
|
||||||
if await redis_cache.get(cache_key) == DOWNLOAD_IN_PROGRESS_FLAG:
|
|
||||||
logger.info("Playback: Download already in progress")
|
|
||||||
return settings.no_cache_video_url
|
return settings.no_cache_video_url
|
||||||
|
|
||||||
# Mark the start of the download
|
# Mark the start of the download
|
||||||
await redis_cache.set(
|
await redis_cache.set(
|
||||||
cache_key, DOWNLOAD_IN_PROGRESS_FLAG, expiration=600 # 10 minute expiration
|
cache_key, DOWNLOAD_IN_PROGRESS_FLAG, expiration=600
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
debrid_service = get_download_service(config)
|
debrid_service = get_download_service(config, debrid_session)
|
||||||
if not debrid_service:
|
if not debrid_service:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail="Download service not available"
|
status_code=500, detail="Download service not available"
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(debrid_service, RealDebrid):
|
if isinstance(debrid_service, RealDebrid):
|
||||||
torrent_id = debrid_service.add_magnet_or_torrent_and_select(query, ip)
|
torrent_id = await debrid_service.add_magnet_or_torrent_and_select(query, ip)
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Playback: Added magnet or torrent to Real-Debrid: {torrent_id}"
|
f"Playback: Added magnet or torrent to Real-Debrid: {torrent_id}"
|
||||||
)
|
)
|
||||||
|
|
@ -152,7 +217,7 @@ async def handle_download(
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
privacy = query.get("privacy", "private")
|
privacy = query.get("privacy", "private")
|
||||||
torrent_info = debrid_service.add_magnet_or_torrent(
|
torrent_info = await debrid_service.add_magnet_or_torrent(
|
||||||
magnet, torrent_download, ip, privacy
|
magnet, torrent_download, ip, privacy
|
||||||
)
|
)
|
||||||
logger.success(
|
logger.success(
|
||||||
|
|
@ -171,8 +236,7 @@ async def handle_download(
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Start background caching
|
if await debrid_service.start_background_caching(magnet, query):
|
||||||
if debrid_service.start_background_caching(magnet, query):
|
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Playback: Started background caching for magnet: {magnet[:50]}"
|
f"Playback: Started background caching for magnet: {magnet[:50]}"
|
||||||
)
|
)
|
||||||
|
|
@ -181,6 +245,8 @@ async def handle_download(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="Failed to start background caching"
|
detail="Failed to start background caching"
|
||||||
)
|
)
|
||||||
|
except DebridError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting background caching: {str(e)}")
|
logger.error(f"Error starting background caching: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -191,45 +257,82 @@ async def handle_download(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await redis_cache.delete(cache_key)
|
await redis_cache.delete(cache_key)
|
||||||
logger.error(f"Playback: Error handling download: {str(e)}", exc_info=True)
|
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
|
raise e
|
||||||
|
|
||||||
|
|
||||||
async def get_stream_link(
|
async def get_stream_link(
|
||||||
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache, cache_user_identifier: str
|
decoded_query: str, config: dict, ip: str, redis_cache: RedisCache, cache_user_identifier: str, stream_id: str = None, debrid_session=None
|
||||||
) -> str:
|
) -> str:
|
||||||
logger.debug(f"Playback: Getting stream link for query: {decoded_query}, IP: {ip}")
|
logger.debug(f"Playback: Getting stream link for query: {decoded_query}, IP: {ip}")
|
||||||
cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
|
|
||||||
|
query = json.loads(decoded_query)
|
||||||
|
if stream_id and query.get("type") == "series":
|
||||||
|
cache_key = f"stream_link:{cache_user_identifier}:{stream_id}:{query.get('service', '')}"
|
||||||
|
current_source_key = f"current_source:{cache_user_identifier}:{stream_id}:{query.get('service', '')}"
|
||||||
|
logger.info(f"Playback: get_stream_link using series cache key: {cache_key}")
|
||||||
|
logger.info(f"Playback: get_stream_link Stream ID: {stream_id}, Service: {query.get('service', '')}")
|
||||||
|
else:
|
||||||
|
cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
|
||||||
|
current_source_key = f"current_source:{cache_user_identifier}:{decoded_query}"
|
||||||
|
logger.info(f"Playback: get_stream_link using fallback cache key: {cache_key}")
|
||||||
|
|
||||||
|
magnet = query.get("magnet")
|
||||||
|
info_hash = query.get("info_hash")
|
||||||
|
service = query.get("service", False)
|
||||||
|
|
||||||
|
if magnet or info_hash:
|
||||||
|
source_info = {
|
||||||
|
"magnet": magnet,
|
||||||
|
"info_hash": info_hash,
|
||||||
|
"raw_title": query.get("title", ""),
|
||||||
|
"service": service,
|
||||||
|
"indexer": query.get("indexer", "")
|
||||||
|
}
|
||||||
|
await redis_cache.set(current_source_key, source_info, expiration=1200)
|
||||||
|
logger.debug(f"Playback: Stored current source for binge group: {magnet[:50] if magnet else info_hash}")
|
||||||
|
|
||||||
cached_link = await redis_cache.get(cache_key)
|
cached_link = await redis_cache.get(cache_key)
|
||||||
if cached_link:
|
if cached_link:
|
||||||
logger.info(f"Playback: Stream link found in cache: {cached_link}")
|
logger.info(f"Playback: Stream link found in cache: {cached_link}")
|
||||||
return cached_link
|
return cached_link
|
||||||
|
|
||||||
logger.debug("Playback: Stream link not found in cache, generating new link")
|
debrid_service = get_download_service(config, debrid_session)
|
||||||
|
|
||||||
query = json.loads(decoded_query)
|
if not debrid_service:
|
||||||
service = query.get("service", False)
|
logger.error("Playback: No debrid service available")
|
||||||
|
raise HTTPException(status_code=500, detail="No debrid service available")
|
||||||
|
|
||||||
if service == "DL":
|
if service:
|
||||||
link = await handle_download(query, config, ip, redis_cache)
|
logger.debug(f"Playback: Getting stream link from {service}")
|
||||||
elif service:
|
link = await debrid_service.get_stream_link(query, config, ip)
|
||||||
debrid_service = get_debrid_service(config, service)
|
|
||||||
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")
|
||||||
else:
|
else:
|
||||||
logger.error("Playback: Service not found in query")
|
logger.error("Playback: Service not found in query")
|
||||||
raise HTTPException(status_code=500, detail="Service not found in query")
|
raise HTTPException(status_code=500, detail="Service not found in query")
|
||||||
|
|
||||||
if link != settings.no_cache_video_url:
|
if link != settings.no_cache_video_url:
|
||||||
logger.debug(f"Playback: Caching new stream link: {link}")
|
logger.debug(f"Playback: Caching new stream link: {link}")
|
||||||
await redis_cache.set(cache_key, link, expiration=3600) # Cache for 1 hour
|
await redis_cache.set(cache_key, link, expiration=1200) # Cache for 20 minutes
|
||||||
logger.info(f"Playback: New stream link generated and cached: {link}")
|
logger.info(f"Playback: New stream link generated and cached: {link}")
|
||||||
else:
|
else:
|
||||||
logger.debug("Playback: Stream link not cached (NO_CACHE_VIDEO_URL)")
|
logger.debug("Playback: Stream link not cached (NO_CACHE_VIDEO_URL)")
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{config}/{query}", responses={500: {"model": ErrorResponse}})
|
@router.api_route("/{config}/{query:path}", methods=["GET", "HEAD"], responses={500: {"model": ErrorResponse}})
|
||||||
@rate_limiter(limit=20, seconds=60, redis=redis_session)
|
|
||||||
async def get_playback(
|
async def get_playback(
|
||||||
config: str,
|
config: str,
|
||||||
query: str,
|
query: str,
|
||||||
|
|
@ -237,10 +340,12 @@ async def get_playback(
|
||||||
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
|
redis_cache: RedisCache = Depends(get_redis_cache_dependency),
|
||||||
apikey_dao: APIKeyDAO = Depends(),
|
apikey_dao: APIKeyDAO = Depends(),
|
||||||
):
|
):
|
||||||
|
if request.method == "HEAD":
|
||||||
|
return Response(status_code=200)
|
||||||
try:
|
try:
|
||||||
config = parse_config(config)
|
config = parse_config(config)
|
||||||
api_key = config.get("apiKey")
|
api_key = config.get("apiKey")
|
||||||
ip = request.client.host
|
ip = get_client_ip(request)
|
||||||
cache_user_identifier = api_key if api_key else ip
|
cache_user_identifier = api_key if api_key else ip
|
||||||
|
|
||||||
# Only validate the API key if it exists
|
# Only validate the API key if it exists
|
||||||
|
|
@ -265,48 +370,80 @@ async def get_playback(
|
||||||
logger.debug(f"Playback: Received playback request for query: {decoded_query}")
|
logger.debug(f"Playback: Received playback request for query: {decoded_query}")
|
||||||
service = query_dict.get("service", False)
|
service = query_dict.get("service", False)
|
||||||
|
|
||||||
|
debrid_session = getattr(request.app.state, 'debrid_session', None)
|
||||||
|
|
||||||
if service == "DL":
|
if service == "DL":
|
||||||
# Pass cache_user_identifier to handle_download if needed, otherwise it uses ip/query_dict
|
result = await handle_download(query_dict, config, ip, redis_cache, debrid_session)
|
||||||
link = await handle_download(query_dict, config, ip, redis_cache)
|
if isinstance(result, Response):
|
||||||
return RedirectResponse(url=link, status_code=status.HTTP_302_FOUND)
|
return result
|
||||||
|
return RedirectResponse(url=result, status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
# Use cache_user_identifier for lock key
|
# Extract stream_id from referer
|
||||||
lock_key = f"lock:stream:{cache_user_identifier}:{decoded_query}"
|
stream_id = None
|
||||||
lock = redis_client.lock(lock_key, timeout=60)
|
referer = request.headers.get("referer", "")
|
||||||
|
if "/stream/" in referer:
|
||||||
try:
|
|
||||||
if await lock.acquire(blocking=False):
|
|
||||||
logger.debug("Playback: Lock acquired, getting stream link")
|
|
||||||
# Pass cache_user_identifier to get_stream_link
|
|
||||||
link = await get_stream_link(decoded_query, config, ip, redis_cache, cache_user_identifier)
|
|
||||||
else:
|
|
||||||
logger.debug("Playback: Lock not acquired, waiting for cached link")
|
|
||||||
# Use cache_user_identifier for cache key lookup
|
|
||||||
cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
|
|
||||||
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:
|
try:
|
||||||
await lock.release()
|
stream_id = referer.split("/stream/")[1].split("/")[-1].replace(".json", "")
|
||||||
logger.debug("Playback: Lock released")
|
except:
|
||||||
except LockError:
|
pass
|
||||||
logger.warning("Playback: Failed to release lock (already released)")
|
|
||||||
|
|
||||||
if not settings.proxied_link:
|
# Fast path: check cache before acquiring lock
|
||||||
|
if stream_id and query_dict.get("type") == "series":
|
||||||
|
fast_cache_key = f"stream_link:{cache_user_identifier}:{stream_id}:{query_dict.get('service', '')}"
|
||||||
|
else:
|
||||||
|
fast_cache_key = f"stream_link:{cache_user_identifier}:{decoded_query}"
|
||||||
|
fast_cached = await redis_cache.get(fast_cache_key)
|
||||||
|
if fast_cached:
|
||||||
|
logger.info("Playback: Fast path cache hit, skipping lock")
|
||||||
|
link = fast_cached
|
||||||
|
else:
|
||||||
|
# Use cache_user_identifier for lock key
|
||||||
|
lock_key = f"lock:stream:{cache_user_identifier}:{decoded_query}"
|
||||||
|
lock = redis_client.lock(lock_key, timeout=60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await lock.acquire(blocking=False):
|
||||||
|
logger.debug("Playback: Lock acquired, getting stream link")
|
||||||
|
link = await get_stream_link(decoded_query, config, ip, redis_cache, cache_user_identifier, stream_id, debrid_session)
|
||||||
|
else:
|
||||||
|
logger.debug("Playback: Lock not acquired, waiting for cached link")
|
||||||
|
for _ in range(60):
|
||||||
|
cached_link = await redis_cache.get(fast_cache_key)
|
||||||
|
if cached_link:
|
||||||
|
logger.debug("Playback: Cached link found while waiting")
|
||||||
|
link = cached_link
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
else:
|
||||||
|
logger.warning("Playback: Timed out waiting for cached link")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Service temporarily unavailable. Please try again.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await lock.release()
|
||||||
|
logger.debug("Playback: Lock released")
|
||||||
|
except LockError:
|
||||||
|
logger.warning("Playback: Failed to release lock (already released)")
|
||||||
|
|
||||||
|
# Vérifier si la proxification est activée pour cette clé API
|
||||||
|
use_proxy = settings.proxied_link # Valeur par défaut
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
try:
|
||||||
|
# Récupérer les informations de la clé API
|
||||||
|
api_key_info = await apikey_dao.get_key_by_uuid(api_key)
|
||||||
|
if api_key_info and hasattr(api_key_info, 'proxied_links'):
|
||||||
|
use_proxy = api_key_info.proxied_links
|
||||||
|
logger.info(f"Playback: API key {api_key} has proxied_links={use_proxy}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Playback: Error checking API key proxification status: {e}")
|
||||||
|
|
||||||
|
if not use_proxy:
|
||||||
logger.debug(f"Playback: Redirecting to non-proxied link: {link}")
|
logger.debug(f"Playback: Redirecting to non-proxied link: {link}")
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=link, status_code=status.HTTP_301_MOVED_PERMANENTLY
|
url=link, status_code=status.HTTP_302_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
if service == "TB": # TODO: Check this for torbox
|
if service == "TB": # TODO: Check this for torbox
|
||||||
|
|
@ -361,6 +498,10 @@ async def get_playback(
|
||||||
background=BackgroundTask(streamer.close),
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Playback: Playback error: {str(e)}", exc_info=True)
|
logger.error(f"Playback: Playback error: {str(e)}", exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -371,98 +512,5 @@ async def get_playback(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.head(
|
# HEAD handler removed - FastAPI automatically handles HEAD requests
|
||||||
"/{config}/{query}",
|
# by routing them to the GET handler (same behavior as Comet)
|
||||||
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):
|
class Meta(BaseModel):
|
||||||
id: str # = Field(alias="_id")
|
id: str # = Field(alias="_id")
|
||||||
name: str = Field(alias="title")
|
name: str
|
||||||
type: str = Field(default="movie")
|
type: str = Field(default="movie")
|
||||||
poster: str | None = None
|
poster: str | None = None
|
||||||
background: str | None = None
|
background: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import pickle
|
import pickle
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
from tmdbv3api import TMDb, Movie, TV, Season, Discover, Find
|
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 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.apikey_dao import APIKeyDAO
|
||||||
|
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
|
||||||
from stream_fusion.settings import settings
|
from stream_fusion.settings import settings
|
||||||
from stream_fusion.utils.parse_config import parse_config
|
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.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 (
|
from stream_fusion.web.root.catalog.schemas import (
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
MetaItem,
|
MetaItem,
|
||||||
|
|
@ -24,8 +23,6 @@ from stream_fusion.logging_config import logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
redis_session = create_redis_session(host=settings.redis_host, port=settings.redis_port, db=settings.redis_db)
|
|
||||||
|
|
||||||
tmdb = TMDb()
|
tmdb = TMDb()
|
||||||
tmdb.api_key = settings.tmdb_api_key
|
tmdb.api_key = settings.tmdb_api_key
|
||||||
tmdb.language = "fr-FR"
|
tmdb.language = "fr-FR"
|
||||||
|
|
@ -77,10 +74,17 @@ def extract_year(date_string):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def create_meta_object(details, item_type: str, imdb_id: str):
|
async def create_meta_object(details, item_type: str, imdb_id: str, include_episodes: bool = True):
|
||||||
|
"""
|
||||||
|
Crée un objet Meta à partir des détails TMDB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
include_episodes: Si False, ne charge pas les épisodes (pour le catalogue).
|
||||||
|
Si True, charge tous les épisodes (pour le endpoint /meta).
|
||||||
|
"""
|
||||||
meta = Meta(
|
meta = Meta(
|
||||||
id=imdb_id,
|
id=imdb_id,
|
||||||
title=details.title if hasattr(details, "title") else details.name,
|
name=getattr(details, "title", None) or getattr(details, "name", None),
|
||||||
type=item_type,
|
type=item_type,
|
||||||
poster=(
|
poster=(
|
||||||
f"https://image.tmdb.org/t/p/w500{details.poster_path}"
|
f"https://image.tmdb.org/t/p/w500{details.poster_path}"
|
||||||
|
|
@ -115,7 +119,8 @@ async def create_meta_object(details, item_type: str, imdb_id: str):
|
||||||
meta.stream = {
|
meta.stream = {
|
||||||
"id": imdb_id
|
"id": imdb_id
|
||||||
}
|
}
|
||||||
elif item_type == "series" and hasattr(details, "seasons"):
|
elif item_type == "series" and include_episodes and hasattr(details, "seasons"):
|
||||||
|
# Charger les épisodes seulement si demandé (pour /meta, pas pour /catalog)
|
||||||
meta.videos = []
|
meta.videos = []
|
||||||
for season in details.seasons:
|
for season in details.seasons:
|
||||||
season_details = await get_tv_season_details(
|
season_details = await get_tv_season_details(
|
||||||
|
|
@ -150,7 +155,6 @@ async def get_tmdb_id_from_imdb(imdb_id: str) -> str:
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{config}/catalog/{type}/{id}/skip={skip}.json", responses={500: {"model": ErrorResponse}}
|
"/{config}/catalog/{type}/{id}/skip={skip}.json", responses={500: {"model": ErrorResponse}}
|
||||||
)
|
)
|
||||||
@rate_limiter(limit=20, seconds=60, redis=redis_session)
|
|
||||||
async def get_catalog(
|
async def get_catalog(
|
||||||
config: str,
|
config: str,
|
||||||
type: str,
|
type: str,
|
||||||
|
|
@ -158,7 +162,8 @@ async def get_catalog(
|
||||||
request: Request,
|
request: Request,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
redis_client: Redis = Depends(get_redis),
|
redis_client: Redis = Depends(get_redis),
|
||||||
apikey_dao: APIKeyDAO = Depends()
|
apikey_dao: APIKeyDAO = Depends(),
|
||||||
|
torrentitem_dao: TorrentItemDAO = Depends()
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
config_data = parse_config(config)
|
config_data = parse_config(config)
|
||||||
|
|
@ -173,12 +178,8 @@ async def get_catalog(
|
||||||
if type not in {"movie", "series"} or id not in {
|
if type not in {"movie", "series"} or id not in {
|
||||||
"latest_movies",
|
"latest_movies",
|
||||||
"recently_added_movies",
|
"recently_added_movies",
|
||||||
"popular_movies",
|
|
||||||
"top_rated_movies",
|
|
||||||
"latest_tv_shows",
|
"latest_tv_shows",
|
||||||
"recently_added_tv_shows",
|
"recently_added_tv_shows",
|
||||||
"popular_tv_shows",
|
|
||||||
"top_rated_tv_shows",
|
|
||||||
}:
|
}:
|
||||||
raise HTTPException(status_code=400, detail="Invalid type or catalog id")
|
raise HTTPException(status_code=400, detail="Invalid type or catalog id")
|
||||||
|
|
||||||
|
|
@ -192,48 +193,105 @@ async def get_catalog(
|
||||||
logger.info(f"Catalog not found in cache for key: {cache_key}. Generating...")
|
logger.info(f"Catalog not found in cache for key: {cache_key}. Generating...")
|
||||||
|
|
||||||
item_ids = []
|
item_ids = []
|
||||||
use_yggflix = config_data.get("yggflix", False)
|
episode_info_map = {} # Mapping tmdb_id -> episode info pour les séries
|
||||||
yggflix_ids = {
|
|
||||||
"latest_movies": "latest_movies",
|
# Map catalog IDs to item types for PostgreSQL queries
|
||||||
"recently_added_movies": "recently_added_movies",
|
catalog_type_map = {
|
||||||
"latest_tv_shows": "latest_tv_shows",
|
"latest_movies": "movie",
|
||||||
"recently_added_tv_shows": "recently_added_tv_shows",
|
"recently_added_movies": "movie",
|
||||||
|
"latest_tv_shows": "series",
|
||||||
|
"recently_added_tv_shows": "series",
|
||||||
}
|
}
|
||||||
|
|
||||||
if use_yggflix and id in yggflix_ids:
|
item_type = catalog_type_map.get(id)
|
||||||
|
|
||||||
|
# For "latest_*": Different logic for movies vs series
|
||||||
|
if id.startswith("latest_"):
|
||||||
try:
|
try:
|
||||||
logger.info(f"Attempting to fetch catalog from Yggflix for id: {yggflix_ids[id]}")
|
if item_type == "movie":
|
||||||
yggflix = YggflixAPI()
|
# Films: TMDB discover (films récents des 6 derniers mois) + filtre FR disponible en PostgreSQL
|
||||||
home_data = await asyncio.to_thread(yggflix.get_home)
|
logger.info(f"Fetching latest movies from TMDB discover (last 6 months) and filtering by PostgreSQL availability")
|
||||||
item_ids = [item["id"] for item in home_data.get(yggflix_ids[id], []) if "id" in item]
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
logger.info(f"Fetched {len(item_ids)} IDs from Yggflix for {yggflix_ids[id]}")
|
six_months_ago = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d')
|
||||||
except Exception as ygg_error:
|
tmdb_results = await asyncio.gather(
|
||||||
logger.warning(f"Failed to fetch catalog from Yggflix for id {yggflix_ids[id]}: {ygg_error}. Falling back to TMDb.")
|
*[asyncio.to_thread(discover.discover_movies, {
|
||||||
|
'sort_by': 'popularity.desc',
|
||||||
|
'primary_release_date.gte': six_months_ago,
|
||||||
|
'primary_release_date.lte': today,
|
||||||
|
'page': page,
|
||||||
|
}) for page in range(1, 11)]
|
||||||
|
)
|
||||||
|
all_tmdb_ids = []
|
||||||
|
for results in tmdb_results:
|
||||||
|
for item in results:
|
||||||
|
if hasattr(item, 'id'):
|
||||||
|
all_tmdb_ids.append(item.id)
|
||||||
|
logger.info(f"Fetched {len(all_tmdb_ids)} TMDB IDs from TMDB discover for {id}")
|
||||||
|
|
||||||
|
# Filtre par disponibilité FR/MULTI, trié par date d'ajout en base (plus récent en premier)
|
||||||
|
item_ids = await torrentitem_dao.filter_existing_tmdb_ids(all_tmdb_ids, item_type, sort_by_added=True)
|
||||||
|
logger.info(f"Filtered to {len(item_ids)} available TMDB IDs (FR/MULTI, sorted by added date) for {id}")
|
||||||
|
else:
|
||||||
|
# Séries: TMDB discover avec air_date récent (7 derniers jours) + filtre FR en PostgreSQL
|
||||||
|
# Utilise discover au lieu de on_the_air car on_the_air vire trop vite les séries binge-release
|
||||||
|
logger.info(f"Fetching latest series from TMDB discover (air_date last 7 days) and filtering by PostgreSQL availability")
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
week_ago = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
|
||||||
|
tmdb_results = await asyncio.gather(
|
||||||
|
*[asyncio.to_thread(discover.discover_tv_shows, {
|
||||||
|
'air_date.gte': week_ago,
|
||||||
|
'without_genres': '10763,10764,10766,10767',
|
||||||
|
'air_date.lte': today,
|
||||||
|
'sort_by': 'popularity.desc',
|
||||||
|
'page': page
|
||||||
|
}) for page in range(1, 11)]
|
||||||
|
)
|
||||||
|
all_tmdb_ids = []
|
||||||
|
for results in tmdb_results:
|
||||||
|
for item in results:
|
||||||
|
if hasattr(item, 'id'):
|
||||||
|
all_tmdb_ids.append(item.id)
|
||||||
|
logger.info(f"Fetched {len(all_tmdb_ids)} series TMDB IDs from TMDB discover (air_date) for {id}")
|
||||||
|
|
||||||
|
# Filtre par disponibilité FR/MULTI, trié par dernier nouvel épisode en base
|
||||||
|
# Récupère aussi les infos d'épisode pour l'afficher dans le titre
|
||||||
|
episode_data = await torrentitem_dao.filter_existing_tmdb_ids(all_tmdb_ids, item_type, sort_by_added=True, return_episode_info=True)
|
||||||
|
item_ids = [ep['tmdb_id'] for ep in episode_data]
|
||||||
|
# Créer un mapping tmdb_id -> episode info
|
||||||
|
episode_info_map = {ep['tmdb_id']: ep for ep in episode_data}
|
||||||
|
logger.info(f"Filtered to {len(item_ids)} available series TMDB IDs (FR/MULTI, sorted by new episode date) for {id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch latest catalog for {id}: {e}. Falling back to PostgreSQL only.")
|
||||||
|
item_ids = await torrentitem_dao.get_latest_tmdb_ids(item_type, limit=50)
|
||||||
|
|
||||||
|
# For "recently_added_*": Use PostgreSQL directly (recent uploads)
|
||||||
|
elif id.startswith("recently_added_"):
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching recently added TMDB IDs from PostgreSQL for {item_type}")
|
||||||
|
item_ids = await torrentitem_dao.get_recently_added_tmdb_ids(item_type, limit=50)
|
||||||
|
logger.info(f"Fetched {len(item_ids)} TMDB IDs from PostgreSQL for {id}")
|
||||||
|
except Exception as pg_error:
|
||||||
|
logger.warning(f"Failed to fetch catalog from PostgreSQL for {id}: {pg_error}")
|
||||||
item_ids = []
|
item_ids = []
|
||||||
|
|
||||||
|
# Fallback to TMDb discover if no results
|
||||||
if not item_ids:
|
if not item_ids:
|
||||||
logger.info(f"Fetching catalog from TMDb for type: {type}, id: {id}")
|
logger.info(f"Fallback: Fetching catalog from TMDb discover for type: {type}, id: {id}")
|
||||||
try:
|
try:
|
||||||
tmdb_params = {"page": 1}
|
tmdb_params = {"page": 1}
|
||||||
if type == "movie":
|
if type == "movie":
|
||||||
if id == "popular_movies": discover_func = discover.discover_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:
|
else:
|
||||||
if id == "popular_tv_shows": discover_func = discover.discover_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')]
|
item_ids = [item.id for item in results if hasattr(item, 'id')]
|
||||||
logger.info(f"Fetched {len(item_ids)} IDs from TMDb for {type}/{id}")
|
logger.info(f"Fetched {len(item_ids)} IDs from TMDb discover for {type}/{id}")
|
||||||
|
|
||||||
except Exception as tmdb_error:
|
except Exception as tmdb_error:
|
||||||
logger.error(f"Failed to fetch catalog from TMDb for {type}/{id}: {tmdb_error}", exc_info=True)
|
logger.error(f"Failed to fetch catalog from TMDb for {type}/{id}: {tmdb_error}", exc_info=True)
|
||||||
item_ids = []
|
item_ids = []
|
||||||
|
|
||||||
metas = []
|
metas = []
|
||||||
pipeline = redis_client.pipeline()
|
pipeline = redis_client.pipeline()
|
||||||
|
|
@ -245,7 +303,19 @@ async def get_catalog(
|
||||||
|
|
||||||
if cached_item:
|
if cached_item:
|
||||||
try:
|
try:
|
||||||
metas.append(Meta.model_validate(cached_item))
|
meta = Meta.model_validate(cached_item)
|
||||||
|
# Ajouter l'info d'épisode au DÉBUT du titre pour les séries (sans modifier le cache)
|
||||||
|
if tmdb_id in episode_info_map:
|
||||||
|
ep_info = episode_info_map[tmdb_id]
|
||||||
|
season = ep_info.get('season', '').strip('[]') or ''
|
||||||
|
episode = ep_info.get('episode', '').strip('[]') or ''
|
||||||
|
if season:
|
||||||
|
if episode:
|
||||||
|
ep_prefix = f"S{season.zfill(2)}E{episode.zfill(2)}"
|
||||||
|
else:
|
||||||
|
ep_prefix = f"S{season.zfill(2)}"
|
||||||
|
meta.name = f"{ep_prefix} - {meta.name}"
|
||||||
|
metas.append(meta)
|
||||||
except Exception as validation_error:
|
except Exception as validation_error:
|
||||||
logger.warning(f"Failed to validate cached meta for TMDB ID {tmdb_id}: {validation_error}")
|
logger.warning(f"Failed to validate cached meta for TMDB ID {tmdb_id}: {validation_error}")
|
||||||
continue
|
continue
|
||||||
|
|
@ -258,18 +328,25 @@ async def get_catalog(
|
||||||
else:
|
else:
|
||||||
details = await get_tv_details(tmdb_id)
|
details = await get_tv_details(tmdb_id)
|
||||||
item_type = "series"
|
item_type = "series"
|
||||||
external_ids = await asyncio.to_thread(tv.external_ids, tmdb_id)
|
# Vérifier le cache tmdbid_to_imdbid avant d'appeler external_ids
|
||||||
imdb_id = external_ids.get("imdb_id")
|
cached_imdb_id = await asyncio.to_thread(redis_client.get, f"tmdbid_to_imdbid:{tmdb_id}")
|
||||||
|
if cached_imdb_id:
|
||||||
|
imdb_id = cached_imdb_id.decode('utf-8') if isinstance(cached_imdb_id, bytes) else cached_imdb_id
|
||||||
|
logger.debug(f"IMDb ID found in cache for TMDB ID {tmdb_id}: {imdb_id}")
|
||||||
|
else:
|
||||||
|
external_ids = await asyncio.to_thread(tv.external_ids, tmdb_id)
|
||||||
|
imdb_id = external_ids.get("imdb_id")
|
||||||
|
|
||||||
if not imdb_id:
|
if not imdb_id:
|
||||||
logger.warning(f"No IMDb ID found for TMDB ID: {tmdb_id}")
|
logger.warning(f"No IMDb ID found for TMDB ID: {tmdb_id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
meta = await create_meta_object(details, item_type, imdb_id)
|
# include_episodes=False pour le catalogue (pas besoin des épisodes)
|
||||||
metas.append(meta)
|
meta = await create_meta_object(details, item_type, imdb_id, include_episodes=False)
|
||||||
|
|
||||||
item_cache_key_imdb = f"imdbid_item:{imdb_id}"
|
item_cache_key_imdb = f"imdbid_item:{imdb_id}"
|
||||||
try:
|
try:
|
||||||
|
# Cacher la version sans l'épisode dans le titre
|
||||||
pipeline.set(
|
pipeline.set(
|
||||||
item_cache_key_tmdb, pickle.dumps(meta), ex=7 * 24 * 60 * 60
|
item_cache_key_tmdb, pickle.dumps(meta), ex=7 * 24 * 60 * 60
|
||||||
)
|
)
|
||||||
|
|
@ -282,6 +359,20 @@ async def get_catalog(
|
||||||
except Exception as cache_err:
|
except Exception as cache_err:
|
||||||
logger.error(f"Error adding item TMDB:{tmdb_id}/IMDB:{imdb_id} to cache pipeline: {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:
|
except Exception as e:
|
||||||
logger.error(f"Error processing item with TMDB ID {tmdb_id}: {str(e)}")
|
logger.error(f"Error processing item with TMDB ID {tmdb_id}: {str(e)}")
|
||||||
continue
|
continue
|
||||||
|
|
@ -310,7 +401,6 @@ async def get_catalog(
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{config}/meta/{type}/{id}.json", responses={500: {"model": ErrorResponse}}
|
"/{config}/meta/{type}/{id}.json", responses={500: {"model": ErrorResponse}}
|
||||||
)
|
)
|
||||||
@rate_limiter(limit=20, seconds=60, redis=redis_session)
|
|
||||||
async def get_meta(
|
async def get_meta(
|
||||||
config: str,
|
config: str,
|
||||||
type: str,
|
type: str,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ async def configure(request: Request):
|
||||||
"ygg_unique_account": settings.ygg_unique_account,
|
"ygg_unique_account": settings.ygg_unique_account,
|
||||||
"jackett_enable": settings.jackett_enable,
|
"jackett_enable": settings.jackett_enable,
|
||||||
"tb_unique_account": settings.tb_unique_account,
|
"tb_unique_account": settings.tb_unique_account,
|
||||||
|
"c411_unique_account": settings.c411_unique_account,
|
||||||
|
"torr9_unique_account": settings.torr9_unique_account,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
import json
|
|
||||||
import queue
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from RTN import ParsedData
|
|
||||||
|
|
||||||
from stream_fusion.constants import FR_RELEASE_GROUPS, FRENCH_PATTERNS
|
|
||||||
from stream_fusion.utils.models.media import Media
|
|
||||||
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
|
||||||
from stream_fusion.utils.string_encoding import encodeb64
|
|
||||||
|
|
||||||
|
|
||||||
INSTANTLY_AVAILABLE = "⚡"
|
|
||||||
DOWNLOAD_REQUIRED = "⬇️"
|
|
||||||
DIRECT_TORRENT = "🏴☠️"
|
|
||||||
|
|
||||||
|
|
||||||
def get_emoji(language):
|
|
||||||
emoji_dict = {
|
|
||||||
"fr": "🇫🇷 FRENCH",
|
|
||||||
"en": "🇬🇧 ENGLISH",
|
|
||||||
"es": "🇪🇸 SPANISH",
|
|
||||||
"de": "🇩🇪 GERMAN",
|
|
||||||
"it": "🇮🇹 ITALIAN",
|
|
||||||
"pt": "🇵🇹 PORTUGUESE",
|
|
||||||
"ru": "🇷🇺 RUSSIAN",
|
|
||||||
"in": "🇮🇳 INDIAN",
|
|
||||||
"nl": "🇳🇱 DUTCH",
|
|
||||||
"hu": "🇭🇺 HUNGARIAN",
|
|
||||||
"la": "🇲🇽 LATINO",
|
|
||||||
"multi": "🌍 MULTi",
|
|
||||||
}
|
|
||||||
return emoji_dict.get(language, "🇬🇧")
|
|
||||||
|
|
||||||
|
|
||||||
def filter_by_availability(item):
|
|
||||||
if item["name"].startswith(INSTANTLY_AVAILABLE):
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def filter_by_direct_torrnet(item):
|
|
||||||
if item["name"].startswith(DIRECT_TORRENT):
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def extract_release_group(title):
|
|
||||||
combined_pattern = "|".join(FR_RELEASE_GROUPS)
|
|
||||||
match = re.search(combined_pattern, title)
|
|
||||||
return match.group(0) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def detect_french_language(title):
|
|
||||||
for language, pattern in FRENCH_PATTERNS.items():
|
|
||||||
match = re.search(pattern, title, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
return language
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def 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": f"stremio-jackett-{torrent_item.info_hash}",
|
|
||||||
"filename": (
|
|
||||||
torrent_item.file_name
|
|
||||||
if torrent_item.file_name is not None
|
|
||||||
else torrent_item.raw_title
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if torrenting and torrent_item.privacy == "public":
|
|
||||||
name = f"{DIRECT_TORRENT}\n{parsed_data.quality}\n"
|
|
||||||
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": f"stremio-jackett-{torrent_item.info_hash}",
|
|
||||||
"filename": (
|
|
||||||
torrent_item.file_name
|
|
||||||
if torrent_item.file_name is not None
|
|
||||||
else torrent_item.raw_title
|
|
||||||
),
|
|
||||||
},
|
|
||||||
# "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
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from uuid import UUID
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
||||||
|
|
@ -23,6 +25,7 @@ from stream_fusion.utils.jackett.jackett_service import JackettService
|
||||||
from stream_fusion.utils.parser.parser_service import StreamParser
|
from stream_fusion.utils.parser.parser_service import StreamParser
|
||||||
from stream_fusion.utils.sharewood.sharewood_service import SharewoodService
|
from stream_fusion.utils.sharewood.sharewood_service import SharewoodService
|
||||||
from stream_fusion.utils.yggfilx.yggflix_service import YggflixService
|
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.cinemeta import Cinemeta
|
||||||
from stream_fusion.utils.metdata.tmdb import TMDB
|
from stream_fusion.utils.metdata.tmdb import TMDB
|
||||||
from stream_fusion.utils.models.movie import Movie
|
from stream_fusion.utils.models.movie import Movie
|
||||||
|
|
@ -31,17 +34,274 @@ 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.security.security_api_key import check_api_key
|
||||||
from stream_fusion.utils.torrent.torrent_item import TorrentItem
|
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.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_service import TorrentService
|
||||||
from stream_fusion.utils.torrent.torrent_smart_container import TorrentSmartContainer
|
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_result import ZileanResult
|
||||||
from stream_fusion.utils.zilean.zilean_service import ZileanService
|
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
|
from stream_fusion.settings import settings
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
titles=media.titles,
|
||||||
|
season=f"S{current_season_num:02d}",
|
||||||
|
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"]),
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
if config["zilean"]:
|
||||||
|
zilean_service = ZileanService(config, session=http_session)
|
||||||
|
zilean_search_results = await zilean_service.search(next_media)
|
||||||
|
if zilean_search_results:
|
||||||
|
zilean_search_results = [
|
||||||
|
ZileanResult().from_api_cached_item(torrent, next_media)
|
||||||
|
for torrent in zilean_search_results
|
||||||
|
if len(getattr(torrent, "info_hash", "")) == 40
|
||||||
|
]
|
||||||
|
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")
|
||||||
|
|
||||||
|
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)
|
||||||
|
filtered_results = ResultsPerQualityFilter(config).filter(search_results)
|
||||||
|
torrent_smart_container = TorrentSmartContainer(filtered_results, next_media)
|
||||||
|
|
||||||
|
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)
|
||||||
|
if result:
|
||||||
|
torrent_smart_container.update_availability(result, type(debrid), next_media)
|
||||||
|
|
||||||
|
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)
|
||||||
|
next_stream_objects = [Stream(**stream) for stream in stream_list]
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.debug(f"Pre-fetch: No results found for episode {next_episode_id}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await background_session.commit()
|
||||||
|
await background_session.close()
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(f"Pre-fetch: Next episode {next_episode_id} already cached")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Pre-fetch: Error during full background pre-fetch: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def simple_prefetch_next_episode(media, config, redis_cache, stream_cache_key, get_metadata, stream_type):
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
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,
|
||||||
|
titles=media.titles,
|
||||||
|
season=f"S{current_season_num:02d}",
|
||||||
|
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 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"]),
|
||||||
|
timeout=3.0
|
||||||
|
)
|
||||||
|
logger.debug(f"Pre-fetch: Metadata cached for episode {next_episode_id}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(f"Pre-fetch: Next episode {next_episode_id} already cached")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug(f"Pre-fetch: Timeout during simple background pre-fetch")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Pre-fetch: Error during simple background pre-fetch: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def prefetch_next_episode(media, config, redis_cache, stream_cache_key, get_metadata, get_and_filter_results, stream_processing, ResultsPerQualityFilter, Stream, stream_type):
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
titles=media.titles,
|
||||||
|
season=f"S{current_season_num:02d}",
|
||||||
|
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.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"]),
|
||||||
|
timeout=8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_results = await asyncio.wait_for(
|
||||||
|
get_and_filter_results(next_media, config),
|
||||||
|
timeout=12.0
|
||||||
|
)
|
||||||
|
filtered_results = ResultsPerQualityFilter(config).filter(raw_results)
|
||||||
|
next_streams = stream_processing(filtered_results, next_media, config)
|
||||||
|
next_stream_objects = [Stream(**stream) for stream in next_streams]
|
||||||
|
|
||||||
|
await redis_cache.set(stream_cache_key(next_media), next_stream_objects, expiration=expiration_time)
|
||||||
|
logger.success(f"Pre-fetch: Successfully background pre-cached {len(next_stream_objects)} streams for episode {next_episode_id}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(f"Pre-fetch: Next episode {next_episode_id} already cached")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug(f"Pre-fetch: Timeout during background pre-fetch")
|
||||||
|
except Exception as e:
|
||||||
|
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)}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Pre-fetch: Error during background pre-fetch: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{config}/stream/{stream_type}/{stream_id}", response_model=SearchResponse)
|
@router.get("/{config}/stream/{stream_type}/{stream_id}", response_model=SearchResponse)
|
||||||
async def get_results(
|
async def get_results(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -58,32 +318,37 @@ async def get_results(
|
||||||
stream_id = stream_id.replace(".json", "")
|
stream_id = stream_id.replace(".json", "")
|
||||||
config = parse_config(config)
|
config = parse_config(config)
|
||||||
api_key = config.get("apiKey")
|
api_key = config.get("apiKey")
|
||||||
ip_address = request.client.host
|
ip_address = get_client_ip(request)
|
||||||
# Only validate the API key if it exists
|
# Only validate the API key if it exists
|
||||||
if api_key:
|
if api_key:
|
||||||
try:
|
await check_api_key(api_key, apikey_dao)
|
||||||
await check_api_key(api_key, apikey_dao)
|
|
||||||
logger.info(f"Search: Valid API key provided by {ip_address}")
|
|
||||||
except HTTPException as e:
|
|
||||||
# Re-raise the exception if validation fails
|
|
||||||
logger.warning(f"Search: Invalid API key provided by {ip_address}. Error: {e.detail}")
|
|
||||||
raise e
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"Search: No API key provided by {ip_address}. Proceeding without API key validation.")
|
logger.warning("Search: API key not found in config.")
|
||||||
|
raise HTTPException(status_code=401, detail="API key not found in config.")
|
||||||
|
|
||||||
debrid_services = get_all_debrid_services(config)
|
debrid_session = getattr(request.app.state, 'debrid_session', None)
|
||||||
|
debrid_services = get_all_debrid_services(config, debrid_session)
|
||||||
logger.debug(f"Search: Found {len(debrid_services)} debrid services")
|
logger.debug(f"Search: Found {len(debrid_services)} debrid services")
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Search: Debrid services: {[debrid.__class__.__name__ for debrid in debrid_services]}"
|
f"Search: Debrid services: {[debrid.__class__.__name__ for debrid in debrid_services]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_metadata():
|
http_session = getattr(request.app.state, 'http_session', None)
|
||||||
|
|
||||||
|
async def get_metadata(episode_id=None, media_type=None):
|
||||||
logger.info(f"Search: Fetching metadata from {config['metadataProvider']}")
|
logger.info(f"Search: Fetching metadata from {config['metadataProvider']}")
|
||||||
|
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:
|
if config["metadataProvider"] == "tmdb" and settings.tmdb_api_key:
|
||||||
metadata_provider = TMDB(config)
|
try:
|
||||||
else:
|
metadata_provider = TMDB(config, session=http_session)
|
||||||
metadata_provider = Cinemeta(config)
|
return await metadata_provider.get_metadata(actual_id, actual_type)
|
||||||
return metadata_provider.get_metadata(stream_id, stream_type)
|
except (ValueError, IndexError, KeyError) as e:
|
||||||
|
logger.warning(f"Search: TMDB metadata fetch failed ({str(e)}), falling back to Cinemeta")
|
||||||
|
|
||||||
|
metadata_provider = Cinemeta(config, session=http_session)
|
||||||
|
return await metadata_provider.get_metadata(actual_id, actual_type)
|
||||||
|
|
||||||
media = await redis_cache.get_or_set(
|
media = await redis_cache.get_or_set(
|
||||||
get_metadata, stream_id, stream_type, config["metadataProvider"]
|
get_metadata, stream_id, stream_type, config["metadataProvider"]
|
||||||
|
|
@ -107,6 +372,11 @@ async def get_results(
|
||||||
cached_result = await redis_cache.get(stream_cache_key(media))
|
cached_result = await redis_cache.get(stream_cache_key(media))
|
||||||
if cached_result is not None:
|
if cached_result is not None:
|
||||||
logger.info("Search: Returning cached processed results")
|
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
|
total_time = time.time() - start
|
||||||
logger.success(f"Search: Request completed in {total_time:.2f} seconds")
|
logger.success(f"Search: Request completed in {total_time:.2f} seconds")
|
||||||
return SearchResponse(streams=cached_result)
|
return SearchResponse(streams=cached_result)
|
||||||
|
|
@ -115,7 +385,7 @@ async def get_results(
|
||||||
if isinstance(media, Movie):
|
if isinstance(media, Movie):
|
||||||
key_string = f"media:{media.titles[0]}:{media.year}:{media.languages[0]}"
|
key_string = f"media:{media.titles[0]}:{media.year}:{media.languages[0]}"
|
||||||
elif isinstance(media, Series):
|
elif isinstance(media, Series):
|
||||||
key_string = f"media:{media.titles[0]}:{media.languages[0]}:{media.season}"
|
key_string = f"media:{media.titles[0]}:{media.languages[0]}:{media.season}{media.episode}"
|
||||||
else:
|
else:
|
||||||
raise TypeError("Only Movie and Series are allowed as media!")
|
raise TypeError("Only Movie and Series are allowed as media!")
|
||||||
hashed_key = hashlib.sha256(key_string.encode("utf-8")).hexdigest()
|
hashed_key = hashlib.sha256(key_string.encode("utf-8")).hexdigest()
|
||||||
|
|
@ -129,8 +399,69 @@ async def get_results(
|
||||||
nonlocal search_results
|
nonlocal search_results
|
||||||
search_results = []
|
search_results = []
|
||||||
|
|
||||||
if config["cache"] and not update_cache:
|
async def _fetch_c411_raw():
|
||||||
public_cached_results = search_public(media)
|
if not config.get("c411"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
c411_service = C411Service(config, session=http_session)
|
||||||
|
raw = await c411_service.search(media)
|
||||||
|
return [
|
||||||
|
C411SearchResult().from_api_item(item, media)
|
||||||
|
for item in raw
|
||||||
|
if getattr(item, "info_hash", None) and len(item.info_hash) == 40
|
||||||
|
] if raw else []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search: C411 search failed, skipping: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _fetch_torr9_raw():
|
||||||
|
if not config.get("torr9"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
torr9_service = Torr9Service(config, session=http_session)
|
||||||
|
raw = await torr9_service.search(media)
|
||||||
|
return [
|
||||||
|
Torr9SearchResult().from_api_item(item, media)
|
||||||
|
for item in raw
|
||||||
|
if getattr(item, "info_hash", None) and len(item.info_hash) == 40
|
||||||
|
] if raw else []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search: Torr9 search failed, skipping: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _fetch_yggflix_raw():
|
||||||
|
if not config.get("yggflix"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
yggflix_service = YggflixService(config)
|
||||||
|
raw = await asyncio.to_thread(yggflix_service.search, media)
|
||||||
|
return raw if raw else []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search: Yggflix search failed, skipping: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
c411_raw, torr9_raw, yggflix_raw = await asyncio.gather(
|
||||||
|
_fetch_c411_raw(), _fetch_torr9_raw(), _fetch_yggflix_raw()
|
||||||
|
)
|
||||||
|
|
||||||
|
if c411_raw:
|
||||||
|
c411_search_results = await torrent_service.convert_and_process(c411_raw)
|
||||||
|
logger.success(f"Search: Found {len(c411_search_results)} results from C411")
|
||||||
|
search_results = merge_items(search_results, c411_search_results)
|
||||||
|
if torr9_raw:
|
||||||
|
torr9_search_results = await torrent_service.convert_and_process(torr9_raw)
|
||||||
|
logger.success(f"Search: Found {len(torr9_search_results)} results from Torr9")
|
||||||
|
search_results = merge_items(search_results, torr9_search_results)
|
||||||
|
if yggflix_raw:
|
||||||
|
yggflix_search_results = await torrent_service.convert_and_process(yggflix_raw)
|
||||||
|
logger.success(f"Search: Found {len(yggflix_search_results)} results from Yggflix")
|
||||||
|
search_results = merge_items(search_results, yggflix_search_results)
|
||||||
|
|
||||||
|
# 2. Public cache (DMM etc.)
|
||||||
|
if config["cache"] and not update_cache and len(search_results) < int(
|
||||||
|
config["minCachedResults"]
|
||||||
|
):
|
||||||
|
public_cached_results = await asyncio.to_thread(search_public, media)
|
||||||
if public_cached_results:
|
if public_cached_results:
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Search: Found {len(public_cached_results)} public cached results"
|
f"Search: Found {len(public_cached_results)} public cached results"
|
||||||
|
|
@ -138,20 +469,19 @@ async def get_results(
|
||||||
public_cached_results = [
|
public_cached_results = [
|
||||||
JackettResult().from_cached_item(torrent, media)
|
JackettResult().from_cached_item(torrent, media)
|
||||||
for torrent in public_cached_results
|
for torrent in public_cached_results
|
||||||
if len(torrent.get("hash", "")) == 40
|
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 = await torrent_service.convert_and_process(
|
||||||
public_cached_results
|
public_cached_results
|
||||||
)
|
)
|
||||||
search_results.extend(public_cached_results)
|
search_results = merge_items(search_results, public_cached_results)
|
||||||
|
|
||||||
# Prioriser Zilean en premier
|
# 3. Zilean si pas assez de résultats
|
||||||
if config["zilean"]:
|
if config["zilean"] and len(search_results) < int(
|
||||||
zilean_service = ZileanService(config)
|
config["minCachedResults"]
|
||||||
zilean_search_results = zilean_service.search(media)
|
):
|
||||||
|
zilean_service = ZileanService(config, session=http_session)
|
||||||
|
zilean_search_results = await zilean_service.search(media)
|
||||||
if zilean_search_results:
|
if zilean_search_results:
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Search: Found {len(zilean_search_results)} results from Zilean"
|
f"Search: Found {len(zilean_search_results)} results from Zilean"
|
||||||
|
|
@ -161,9 +491,6 @@ async def get_results(
|
||||||
for torrent in zilean_search_results
|
for torrent in zilean_search_results
|
||||||
if len(getattr(torrent, "info_hash", "")) == 40
|
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 = await torrent_service.convert_and_process(
|
||||||
zilean_search_results
|
zilean_search_results
|
||||||
)
|
)
|
||||||
|
|
@ -172,37 +499,16 @@ async def get_results(
|
||||||
)
|
)
|
||||||
search_results = merge_items(search_results, zilean_search_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(
|
if config["sharewood"] and len(search_results) < int(
|
||||||
config["minCachedResults"]
|
config["minCachedResults"]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
sharewood_service = SharewoodService(config)
|
sharewood_service = SharewoodService(config, session=http_session)
|
||||||
sharewood_search_results = sharewood_service.search(media)
|
sharewood_search_results = await sharewood_service.search(media)
|
||||||
if sharewood_search_results:
|
if sharewood_search_results:
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Search: Found {len(sharewood_search_results)} results from Sharewood"
|
f"Search: Found {len(sharewood_search_results)} results from Sharewood"
|
||||||
)
|
)
|
||||||
sharewood_search_results = filter_items(
|
|
||||||
sharewood_search_results, media, config=config
|
|
||||||
)
|
|
||||||
sharewood_search_results = (
|
sharewood_search_results = (
|
||||||
await torrent_service.convert_and_process(
|
await torrent_service.convert_and_process(
|
||||||
sharewood_search_results
|
sharewood_search_results
|
||||||
|
|
@ -215,17 +521,14 @@ async def get_results(
|
||||||
if config["jackett"] and len(search_results) < int(
|
if config["jackett"] and len(search_results) < int(
|
||||||
config["minCachedResults"]
|
config["minCachedResults"]
|
||||||
):
|
):
|
||||||
jackett_service = JackettService(config)
|
jackett_service = JackettService(config, session=http_session)
|
||||||
jackett_search_results = jackett_service.search(media)
|
jackett_search_results = await jackett_service.search(media)
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Search: Found {len(jackett_search_results)} results from Jackett"
|
f"Search: Found {len(jackett_search_results)} results from Jackett"
|
||||||
)
|
)
|
||||||
filtered_jackett_search_results = filter_items(
|
if jackett_search_results:
|
||||||
jackett_search_results, media, config=config
|
|
||||||
)
|
|
||||||
if filtered_jackett_search_results:
|
|
||||||
torrent_results = await torrent_service.convert_and_process(
|
torrent_results = await torrent_service.convert_and_process(
|
||||||
filtered_jackett_search_results
|
jackett_search_results
|
||||||
)
|
)
|
||||||
search_results = merge_items(search_results, torrent_results)
|
search_results = merge_items(search_results, torrent_results)
|
||||||
|
|
||||||
|
|
@ -236,7 +539,7 @@ async def get_results(
|
||||||
try:
|
try:
|
||||||
cache_key = media_cache_key(media)
|
cache_key = media_cache_key(media)
|
||||||
search_results_dict = [item.to_dict() for item in search_results]
|
search_results_dict = [item.to_dict() for item in search_results]
|
||||||
await redis_cache.set(cache_key, search_results_dict)
|
await redis_cache.set(cache_key, search_results_dict, expiration=settings.redis_expiration)
|
||||||
logger.success("Search: Cache update successful")
|
logger.success("Search: Cache update successful")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Search: Error updating cache: {e}")
|
logger.error(f"Search: Error updating cache: {e}")
|
||||||
|
|
@ -245,38 +548,63 @@ async def get_results(
|
||||||
return search_results
|
return search_results
|
||||||
|
|
||||||
async def get_and_filter_results(media, config):
|
async def get_and_filter_results(media, config):
|
||||||
min_results = int(config.get("minCachedResults", 5))
|
# Postgres acts as a local cache for private indexers (Yggtorrent, C411, Torr9)
|
||||||
cache_key = media_cache_key(media)
|
# and is always queried directly, bypassing Redis
|
||||||
|
postgres_results = []
|
||||||
|
if hasattr(media, 'tmdb_id') and media.tmdb_id:
|
||||||
|
try:
|
||||||
|
postgres_items = await torrent_dao.search_by_tmdb_id(int(media.tmdb_id))
|
||||||
|
if postgres_items:
|
||||||
|
logger.success(
|
||||||
|
f"Search: Found {len(postgres_items)} results from Postgres (local cache) for TMDB ID {media.tmdb_id}"
|
||||||
|
)
|
||||||
|
torrent_service = TorrentService(config, torrent_dao)
|
||||||
|
for db_item in postgres_items:
|
||||||
|
if db_item.indexer in ['Yggtorrent - API', 'C411 - API', 'Torr9 - API']:
|
||||||
|
torrent_item = db_item.to_torrent_item()
|
||||||
|
postgres_results.append(torrent_item)
|
||||||
|
except Exception as pg_error:
|
||||||
|
logger.error(f"Search: Postgres search failed: {str(pg_error)}")
|
||||||
|
|
||||||
unfiltered_results = await redis_cache.get(cache_key)
|
cache_key = media_cache_key(media)
|
||||||
if unfiltered_results is None:
|
external_results = await redis_cache.get(cache_key)
|
||||||
logger.debug("Search: No results in cache. Performing new search.")
|
|
||||||
nocache_results = await get_search_results(media, config)
|
if external_results is None:
|
||||||
nocache_results_dict = [item.to_dict() for item in nocache_results]
|
logger.info("Search: No external sources in Redis cache. Performing new search.")
|
||||||
await redis_cache.set(cache_key, nocache_results_dict)
|
external_results = await get_search_results(media, config)
|
||||||
logger.info(
|
external_results_dict = [item.to_dict() for item in external_results]
|
||||||
f"Search: New search completed, found {len(nocache_results)} results"
|
await redis_cache.set(cache_key, external_results_dict, expiration=settings.redis_expiration)
|
||||||
|
logger.success(
|
||||||
|
f"Search: Cached {len(external_results)} external results in Redis (Sharewood/Zilean/Jackett)"
|
||||||
)
|
)
|
||||||
return nocache_results
|
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.success(
|
||||||
f"Search: Retrieved {len(unfiltered_results)} results from redis cache"
|
f"Search: Retrieved {len(external_results)} external results from Redis cache"
|
||||||
)
|
)
|
||||||
unfiltered_results = [
|
external_results = [
|
||||||
TorrentItem.from_dict(item) for item in unfiltered_results
|
TorrentItem.from_dict(item) for item in external_results
|
||||||
]
|
]
|
||||||
|
|
||||||
filtered_results = filter_items(unfiltered_results, media, config=config)
|
all_results = merge_items(postgres_results, external_results)
|
||||||
|
logger.info(f"Search: Merged Postgres ({len(postgres_results)}) + External ({len(external_results)}) = {len(all_results)} total results")
|
||||||
|
|
||||||
if len(filtered_results) < min_results:
|
filtered_results = filter_items(all_results, media, config=config)
|
||||||
logger.info(
|
|
||||||
f"Search: Insufficient filtered results ({len(filtered_results)}). Performing new search."
|
min_results = int(config.get("minCachedResults", 8))
|
||||||
|
external_filtered = filter_items(external_results, media, config=config)
|
||||||
|
if len(external_filtered) < min_results:
|
||||||
|
logger.warning(
|
||||||
|
f"Search: Insufficient external results ({len(external_filtered)} < {min_results}). Recreating external cache."
|
||||||
)
|
)
|
||||||
await redis_cache.delete(cache_key)
|
await redis_cache.delete(cache_key)
|
||||||
unfiltered_results = await get_search_results(media, config)
|
external_results = await get_search_results(media, config)
|
||||||
unfiltered_results_dict = [item.to_dict() for item in unfiltered_results]
|
external_results_dict = [item.to_dict() for item in external_results]
|
||||||
await redis_cache.set(cache_key, unfiltered_results_dict)
|
await redis_cache.set(cache_key, external_results_dict, expiration=settings.redis_expiration)
|
||||||
filtered_results = filter_items(unfiltered_results, media, config=config)
|
logger.success(
|
||||||
|
f"Search: Recreated external cache with {len(external_results)} results"
|
||||||
|
)
|
||||||
|
all_results = merge_items(postgres_results, external_results)
|
||||||
|
filtered_results = filter_items(all_results, media, config=config)
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
f"Search: Final number of filtered results: {len(filtered_results)}"
|
f"Search: Final number of filtered results: {len(filtered_results)}"
|
||||||
|
|
@ -288,20 +616,28 @@ async def get_results(
|
||||||
search_results = ResultsPerQualityFilter(config).filter(raw_search_results)
|
search_results = ResultsPerQualityFilter(config).filter(raw_search_results)
|
||||||
logger.info(f"Search: Filtered search results per quality: {len(search_results)}")
|
logger.info(f"Search: Filtered search results per quality: {len(search_results)}")
|
||||||
|
|
||||||
def stream_processing(search_results, media, config):
|
async def stream_processing(search_results, media, config):
|
||||||
torrent_smart_container = TorrentSmartContainer(search_results, media)
|
torrent_smart_container = TorrentSmartContainer(search_results, media)
|
||||||
|
|
||||||
if config["debrid"]:
|
if config["debrid"]:
|
||||||
for debrid in debrid_services:
|
for debrid in debrid_services:
|
||||||
hashes = torrent_smart_container.get_unaviable_hashes()
|
hashes = torrent_smart_container.get_unaviable_hashes()
|
||||||
ip = request.client.host
|
ip = get_client_ip(request)
|
||||||
result = debrid.get_availability_bulk(hashes, ip)
|
result = await debrid.get_availability_bulk(hashes, ip)
|
||||||
if result:
|
if result:
|
||||||
torrent_smart_container.update_availability(
|
torrent_smart_container.update_availability(
|
||||||
result, type(debrid), media
|
result, type(debrid), media
|
||||||
)
|
)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
count = len(result.items())
|
||||||
|
else:
|
||||||
|
count = len(result)
|
||||||
|
|
||||||
|
is_stremthru = (type(debrid).__name__ == "StremThru" or
|
||||||
|
hasattr(debrid, 'store_name') and getattr(debrid, 'store_name', None) is not None)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Search: Checked availability for {len(result.items())} items with {type(debrid).__name__}"
|
f"Search: Checked availability for {count} items with {type(debrid).__name__}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -309,7 +645,6 @@ async def get_results(
|
||||||
)
|
)
|
||||||
|
|
||||||
if config["cache"]:
|
if config["cache"]:
|
||||||
logger.info("Search: Caching public container items")
|
|
||||||
torrent_smart_container.cache_container_items()
|
torrent_smart_container.cache_container_items()
|
||||||
|
|
||||||
best_matching_results = torrent_smart_container.get_best_matching()
|
best_matching_results = torrent_smart_container.get_best_matching()
|
||||||
|
|
@ -317,14 +652,28 @@ async def get_results(
|
||||||
logger.info(f"Search: Found {len(best_matching_results)} best matching results")
|
logger.info(f"Search: Found {len(best_matching_results)} best matching results")
|
||||||
|
|
||||||
parser = StreamParser(config)
|
parser = StreamParser(config)
|
||||||
stream_list = parser.parse_to_stremio_streams(best_matching_results, media)
|
stream_list = await parser.parse_to_stremio_streams(best_matching_results, media)
|
||||||
logger.success(f"Search: Processed {len(stream_list)} streams for Stremio")
|
logger.success(f"Search: Processed {len(stream_list)} streams for Stremio")
|
||||||
|
|
||||||
return stream_list
|
return stream_list
|
||||||
|
|
||||||
stream_list = stream_processing(search_results, media, config)
|
stream_list = await stream_processing(search_results, media, config)
|
||||||
streams = [Stream(**stream) for stream in stream_list]
|
streams = [Stream(**stream) for stream in stream_list]
|
||||||
await redis_cache.set(stream_cache_key(media), streams, expiration=1200)
|
|
||||||
|
expiration_time = 1200
|
||||||
|
has_stremthru = any(
|
||||||
|
type(debrid).__name__ == "StremThru" or hasattr(debrid, 'store_name')
|
||||||
|
for debrid in debrid_services
|
||||||
|
)
|
||||||
|
if has_stremthru:
|
||||||
|
expiration_time = 600
|
||||||
|
logger.info(f"Search: Using reduced cache expiration ({expiration_time}s) for StremThru")
|
||||||
|
|
||||||
|
await redis_cache.set(stream_cache_key(media), streams, expiration=expiration_time)
|
||||||
|
|
||||||
|
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
|
total_time = time.time() - start
|
||||||
logger.info(f"Search: Request completed in {total_time:.2f} seconds")
|
logger.info(f"Search: Request completed in {total_time:.2f} seconds")
|
||||||
return SearchResponse(streams=streams)
|
return SearchResponse(streams=streams)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue