Compare commits

...

97 commits
dev ... master

Author SHA1 Message Date
Telkaoss
59a1386d5b update 2026-03-02 17:18:40 +01:00
Telkaoss
a834fea508 update 2026-03-02 17:12:43 +01:00
Telkaoss
817be7fd7d
Add workflow_dispatch to docker-release.yml 2026-03-02 12:02:40 +01:00
Telkaoss
4fc4a5cacc update 2026-03-02 11:32:28 +01:00
Telkaoss
563a4129a8
Add files via upload 2026-02-25 11:56:56 +01:00
Telkaoss
bd66a772f7
Add files via upload 2026-02-25 10:16:48 +01:00
Telkaoss
40bdc41334
Add infoHash to playback URL generation 2026-01-07 11:51:51 +01:00
Telkaoss
15029df552
Add infoHash to playback URL generation 2026-01-07 11:43:23 +01:00
Telkaoss
02b3794ce6
Add files via upload 2025-11-07 02:02:29 +01:00
Telkaoss
465ea3ce22
Add flatten_files function for AllDebrid API response
Introduced a new function to flatten nested file structures from AllDebrid API responses. Updated the logic to handle flattened files for both movies and series.
2025-10-30 00:08:37 +01:00
Telkaoss
26c2cee770
Update magnet file retrieval and error handling
Refactor magnet handling to use v4 API and improve error handling.
2025-10-29 22:15:32 +01:00
Telkaoss
12c9c78368
update index 2025-10-16 09:27:07 +02:00
Telkaoss
b562f09859
Update index 2025-10-16 09:24:40 +02:00
Telkaoss
ec6883be22
Delete stream_fusion/utils/debrid/premiusize.py.save 2025-10-11 14:44:16 +02:00
Telkaoss
fd0c29fb01
Update views.py 2025-10-11 14:42:57 +02:00
Telkaoss
ca8479e924
Create 2025-01-11-00-00_add_proxied_links.py 2025-10-11 14:39:44 +02:00
Telkaoss
3c57fe0011
Delete stream_fusion/services/postgresql/migrations/2025-01-11-00-00_add_proxied_links.py 2025-10-11 14:38:37 +02:00
Telkaoss
3f9039622d
Create 2025-01-11-00-00_add_proxied_links.py 2025-10-11 14:37:53 +02:00
Telkaoss
0ce7888fd7
Update filter_results.py 2025-07-09 04:43:22 +02:00
Telkaoss
4871acd4b6
Update alldebrid.py 2025-06-28 06:16:36 +02:00
Telkaoss
57eae813bb
Update views.py 2025-06-28 06:16:00 +02:00
Telkaoss
d47d4b5d83
Update alldebrid.py 2025-06-27 11:36:53 +02:00
Telkaoss
2a0eba1eff
Update views.py 2025-06-27 10:24:32 +02:00
Telkaoss
9474f4ee61
Update views.py 2025-06-25 04:21:06 +02:00
Telkaoss
cc4cd54e80
Update views.py 2025-06-25 03:59:44 +02:00
Telkaoss
de2654a345
Update views.py 2025-06-17 13:43:01 +02:00
Telkaoss
ee8e37b02e
Update parser_service.py 2025-06-17 13:41:02 +02:00
Telkaoss
250dead391
Update stremio_parser.py 2025-06-17 13:40:22 +02:00
Telkaoss
f3667a68d0
Update views.py 2025-06-17 13:40:11 +02:00
Telkaoss
834939756b
Update parser_service.py 2025-06-16 13:31:14 +02:00
Telkaoss
82e12bf6d8
Update stremio_parser.py 2025-06-16 13:30:21 +02:00
Telkaoss
8bf9ce6545
Update stremio_parser.py 2025-06-16 13:16:08 +02:00
Telkaoss
35f0c45ada
Update parser_service.py 2025-06-16 13:15:47 +02:00
Telkaoss
5772a7441c
Update stremio_parser.py 2025-06-16 12:52:40 +02:00
Telkaoss
888ac83384
Update parser_service.py 2025-06-16 12:51:24 +02:00
Telkaoss
84b102c2f0
Update stremthru.py 2025-06-12 13:18:43 +02:00
Telkaoss
61e7298f26
Update stremthru.py 2025-06-12 11:16:06 +02:00
Telkaoss
ae296e4949
Update stremthru.py 2025-06-12 10:41:33 +02:00
Telkaoss
02df4f4f7f
Update stremthru.py 2025-06-12 10:10:23 +02:00
Telkaoss
255470cafa update poetry.lock with RTN 1.9.0 2025-06-12 01:44:30 +00:00
Telkaoss
3e19c889cc
Update pyproject.toml 2025-06-12 03:11:10 +02:00
Telkaoss
1535082d31
Update pyproject.toml 2025-06-12 02:59:07 +02:00
Telkaoss
7cbca804f9 update 2025-06-12 02:55:07 +02:00
Telkaoss
714b683c0b
Update views.py 2025-06-11 13:37:56 +02:00
Telkaoss
79c720e8a5
Update apikey_dao.py 2025-06-11 12:38:38 +02:00
Telkaoss
0955bf80aa
Update views.py 2025-06-11 12:12:36 +02:00
Telkaoss
922992f166
Delete Dockerfile.simple 2025-05-28 04:35:13 +02:00
Telkaoss
c2a780a909
Delete Dockerfile.custom 2025-05-28 04:34:59 +02:00
Telkaoss
f34513e2d6
Delete stream-fusion-1.1.9.code-workspace 2025-05-28 04:34:37 +02:00
Telkaoss
a0e3e477b1
Update language_priority_filter.py 2025-05-27 15:59:26 +02:00
Telkaoss
e2e2f59d7c
Update index.html 2025-05-27 15:57:46 +02:00
Telkaoss
30f28b8da5
Update config.js 2025-05-27 15:55:37 +02:00
Telkaoss
5a48e8c5e6
Delete stream_fusion/utils/language_priority_filter.py 2025-05-27 14:38:30 +02:00
Telkaoss
eaa42d52b7 Merge branch 'master' of https://github.com/Telkaoss/stream-fusion 2025-05-27 14:37:07 +02:00
Telkaoss
b4446353fe update 2025-05-27 14:36:40 +02:00
Telkaoss
a966d33de5
Create language_priority_filter.py 2025-05-27 14:34:51 +02:00
Telkaoss
b570822ad6
Update apikey_dao.py 2025-05-23 06:08:48 +02:00
Telkaoss
8fc8c70e83
Update apikey_model.py 2025-05-23 05:49:21 +02:00
Telkaoss
90cb7e70fd
Update apikey_schemas.py 2025-05-23 05:48:46 +02:00
Telkaoss
7be417f4c1 update 2025-05-23 05:31:13 +02:00
Telkaoss
2c1892c260 update 2025-05-23 04:48:08 +02:00
Telkaoss
f2d7e1589f
Update schemas.py 2025-05-23 04:31:02 +02:00
Telkaoss
86d37233c9
Update views.py 2025-05-23 04:30:29 +02:00
Telkaoss
e196b6413a
Update api_keys.html 2025-05-23 04:29:06 +02:00
Telkaoss
88e2ffe875
Update views.py 2025-05-23 04:28:32 +02:00
Telkaoss
7a5caab47c
Update create_api_key.html 2025-05-23 04:26:27 +02:00
Telkaoss
ab7e8bfaf3
Update __init__.py 2025-05-23 04:22:58 +02:00
Telkaoss
202a5531d8
Create __init__.py 2025-05-23 04:03:59 +02:00
Telkaoss
a7bfa9dcd2
Create db_init.py 2025-05-23 04:03:25 +02:00
Telkaoss
6f1c16ee06
Update __init__.py 2025-05-23 04:02:34 +02:00
Telkaoss
ddeec4d5ab
Update apikey_dao.py 2025-05-23 04:01:50 +02:00
Telkaoss
5f5173f26c
Update torrent_service.py 2025-05-21 08:12:20 +02:00
Telkaoss
eb1fafbd67
Update filter_results.py 2025-05-21 08:09:02 +02:00
Telkaoss
b73dcf67b5
Update results_per_quality_filter.py 2025-05-21 08:08:12 +02:00
Telkaoss
37c7948b60
Update results_per_quality_filter.py 2025-05-20 20:40:23 +02:00
Telkaoss
972107876f
Update config.js 2025-05-11 03:31:49 +02:00
Telkaoss
35edbcf29e
Update index.html 2025-05-11 03:31:39 +02:00
Telkaoss
f439065a18
Update cinemeta.py 2025-05-09 03:24:22 +02:00
Telkaoss
fcc1ee1cc9
Update zilean_service.py 2025-05-07 18:37:57 +02:00
Telkaoss
a1f1c1eb2d
Update zilean_api.py 2025-05-07 18:37:40 +02:00
Telkaoss
be4e2f6e32
Update torrent_smart_container.py 2025-05-07 03:25:58 +02:00
Telkaoss
a171a9784a
Update views.py 2025-05-07 02:48:55 +02:00
Telkaoss
03fc7a3aee
Update sharewood_service.py 2025-05-07 02:48:13 +02:00
Telkaoss
26dbfd9fe5
Update yggflix_service.py 2025-05-07 02:47:53 +02:00
Telkaoss
b0fe2f4c78
Update stremthru.py 2025-05-06 23:37:43 +02:00
Telkaoss
9381cd03c1
Update torrent_smart_container.py 2025-05-06 23:36:44 +02:00
Telkaoss
d0cf787650 premiumize bug 2025-05-06 22:02:21 +02:00
Telkaoss
f03dbf473d update 2025-05-06 11:18:44 +02:00
Telkaoss
c76df27bca Stremthru 2025-05-05 05:45:09 +02:00
Telkaoss
83537f2785 Merge branch 'master' of https://github.com/Telkaoss/stream-fusion 2025-05-04 12:46:43 +02:00
Telkaoss
35d35993a3 bug fix 2025-05-04 12:41:57 +02:00
Telkaoss
b292f7d795
Update stremio_parser.py 2025-05-04 12:16:45 +02:00
Telkaoss
a8904f32ca
Update torrent_smart_container.py 2025-05-04 12:15:58 +02:00
Telkaoss
f312c5fcce
Update alldebrid.py 2025-05-04 12:15:20 +02:00
Telkaoss
42503ea6da
Update stremio_parser.py 2025-05-04 10:48:46 +02:00
Telkaoss
e3ed8b57f4 update 2025-05-04 09:31:01 +02:00
Telkaoss
1530d0557c Stremthru, async ++
Async Streaming: Converted key methods (fill_buffer, stream_content, close, get_stream_link, head_playback) to async for non-blocking streaming and improved scalability.

🔄 Unified Stremthru: Full refactor with dynamic STORE_CODE_TO_NAME / STORE_NAME_TO_TOKEN_KEY mappings and propagation of store_name in all requests (add_magnet, unrestrict_link, etc.). Updated RealDebrid to delegate via Stremthru.

💾 Cache Persistence: Store valid Stremthru links in Redis (stremthru:working:{store_code}:{info_hash}, TTL 7 days) and leverage this cache to mark links as "working" even on cached results.
2025-05-04 05:34:36 +02:00
84 changed files with 6310 additions and 2178 deletions

View file

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

528
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View 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"]

View file

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

View file

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

View 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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];
} }

View file

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

View file

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

View file

@ -51,6 +51,9 @@
<label for="ApiKey" class="block text-sm font-medium leading-6 text-red-600 text-center">API Key</label> <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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
# Package initialization for utils

View file

View file

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

View file

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

View file

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

View file

@ -11,6 +11,9 @@ from stream_fusion.settings import settings
def search_public(media): 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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,6 @@
import requests import os
import aiohttp
from typing import Optional
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider from stream_fusion.utils.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"]

View file

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

View file

@ -1,4 +1,6 @@
import requests import asyncio
import aiohttp
from typing import Optional
from stream_fusion.utils.metdata.metadata_provider_base import MetadataProvider from stream_fusion.utils.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

View file

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

View file

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

View file

@ -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, },
}, }
}
)

View file

View 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()

View file

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

View file

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,6 @@ from stream_fusion.logging_config import configure_logging
from stream_fusion.services.postgresql.base import Base from stream_fusion.services.postgresql.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()

View file

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

View file

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

View file

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

View file

@ -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,
}) })

View file

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

View file

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