[
  {
    "file": "app/core/config.py",
    "patch": "Index: app/core/config.py\n===================================================================\n--- app/core/config.py\t\n+++ app/core/config.py\t\n@@ -1,141 +1,145 @@\n \"\"\"Application configuration.\n \n Typed pydantic-settings config. Keep env parsing here only; application code\n should consume settings via ``get_settings()`` so tests can reset/override safely.\n \"\"\"\n \n import json\n from functools import lru_cache\n from typing import Annotated, Any, Self\n \n from pydantic import AnyUrl, BeforeValidator, PostgresDsn, computed_field, model_validator\n from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict\n \n DEFAULT_DEV_SECRET = \"dev-secret-key-change-in-production\"\n \n \n def parse_cors(value: Any) -> list[str] | str:\n     \"\"\"Parse CORS origins from a comma-separated string or JSON-style list.\"\"\"\n     if isinstance(value, str) and not value.startswith(\"[\"):\n         return [item.strip() for item in value.split(\",\") if item.strip()]\n     if isinstance(value, list | str):\n         return value\n     raise ValueError(value)\n \n \n def parse_csv_list(value: Any) -> list[str]:\n     \"\"\"Parse comma-separated or JSON-list environment values into strings.\"\"\"\n     if isinstance(value, str):\n         if value.startswith(\"[\"):\n             parsed = json.loads(value)\n             if not isinstance(parsed, list):\n                 raise ValueError(value)\n             return [str(item).strip() for item in parsed if str(item).strip()]\n         return [item.strip() for item in value.split(\",\") if item.strip()]\n     if isinstance(value, list):\n         return [str(item).strip() for item in value if str(item).strip()]\n     raise ValueError(value)\n \n \n class Settings(BaseSettings):\n     \"\"\"Runtime settings loaded from environment variables and .env.\"\"\"\n \n     model_config = SettingsConfigDict(\n         env_file=\".env\",\n         env_file_encoding=\"utf-8\",\n         env_ignore_empty=True,\n         extra=\"ignore\",\n     )\n \n     # Application\n     PROJECT_NAME: str = \"Streaming App\"\n     ENVIRONMENT: str = \"local\"\n     SECRET_KEY: str = DEFAULT_DEV_SECRET\n     OLD_SECRET_KEYS: Annotated[list[str], NoDecode, BeforeValidator(parse_csv_list)] = []\n     ACCESS_TOKEN_EXPIRE_MINUTES: int = 15\n     REFRESH_TOKEN_EXPIRE_DAYS: int = 30\n     JWT_ISSUER: str = \"streaming-app-api\"\n     JWT_AUDIENCE: str = \"streaming-app-client\"\n \n     # Database\n     POSTGRES_SERVER: str = \"localhost\"\n     POSTGRES_PORT: int = 5432\n     POSTGRES_USER: str = \"streaming\"\n     POSTGRES_PASSWORD: str = \"streaming_password\"\n     POSTGRES_DB: str = \"streaming_app\"\n \n     # Redis\n     REDIS_HOST: str = \"\"\n     REDIS_PORT: int = 6379\n     REDIS_DB: int = 0\n     REDIS_PASSWORD: str = \"\"\n \n     # CORS / host validation\n     BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(parse_cors)] = []\n     ALLOWED_HOSTS: Annotated[list[str], NoDecode, BeforeValidator(parse_csv_list)] = []\n     TRUSTED_PROXY_IPS: Annotated[list[str], NoDecode, BeforeValidator(parse_csv_list)] = []\n \n     # Logging\n     LOG_LEVEL: str = \"INFO\"\n \n     # Rate Limiting\n     RATE_LIMIT_REQUESTS: int = 100\n     RATE_LIMIT_WINDOW: int = 60  # seconds\n     REDIS_RATE_LIMITER: bool = False  # Use Redis-backed rate limiter when True\n \n+    # Prowlarr / Torrent search\n+    PROWLARR_URL: str = \"\"\n+    PROWLARR_API_KEY: str = \"\"\n+\n     # Metadata / TMDB\n     TMDB_API_KEY: str = \"\"\n     TMDB_ACCESS_TOKEN: str = \"\"\n     TMDB_LANGUAGE: str = \"en-US\"\n \n     @computed_field  # type: ignore[prop-decorator]\n     @property\n     def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:  # noqa: N802\n         return PostgresDsn.build(\n             scheme=\"postgresql+asyncpg\",\n             username=self.POSTGRES_USER,\n             password=self.POSTGRES_PASSWORD,\n             host=self.POSTGRES_SERVER,\n             port=self.POSTGRES_PORT,\n             path=self.POSTGRES_DB,\n         )\n \n     @computed_field  # type: ignore[prop-decorator]\n     @property\n     def REDIS_URL(self) -> str:  # noqa: N802\n         if not self.REDIS_HOST:\n             return \"\"\n         if self.REDIS_PASSWORD:\n             return f\"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}\"\n         return f\"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}\"\n \n     @model_validator(mode=\"after\")\n     def _validate_environment(self) -> Self:\n         if self.ENVIRONMENT.lower() == \"production\":\n             if self.SECRET_KEY == DEFAULT_DEV_SECRET:\n                 raise ValueError(\"SECRET_KEY must be changed in production\")\n             if len(self.SECRET_KEY) < 32:\n                 raise ValueError(\"SECRET_KEY must be at least 32 characters in production\")\n             if not self.ALLOWED_HOSTS:\n                 raise ValueError(\"ALLOWED_HOSTS must be configured in production\")\n             if \"*\" in self.ALLOWED_HOSTS:\n                 raise ValueError(\"ALLOWED_HOSTS must not contain '*' in production\")\n         return self\n \n \n @lru_cache(maxsize=1)\n def get_settings() -> Settings:\n     \"\"\"Return the cached settings singleton.\"\"\"\n     return Settings()\n \n \n def reset_settings_cache() -> None:\n     \"\"\"Clear cached settings. Intended for tests only.\"\"\"\n     get_settings.cache_clear()\n \n \n def __getattr__(name: str) -> Settings:\n     \"\"\"Backward-compatible lazy ``settings`` attribute.\"\"\"\n     if name == \"settings\":\n         return get_settings()\n     raise AttributeError(f\"module {__name__!r} has no attribute {name!r}\")\n",
    "additions": 4,
    "deletions": 0,
    "status": "modified"
  },
  {
    "file": "app/modules/addons/application/interfaces.py",
    "patch": "Index: app/modules/addons/application/interfaces.py\n===================================================================\n--- app/modules/addons/application/interfaces.py\t\n+++ app/modules/addons/application/interfaces.py\t\n@@ -1,52 +1,79 @@\n \"\"\"Addons module application interfaces (ports).\"\"\"\n \n from __future__ import annotations\n \n+from dataclasses import dataclass, field\n from typing import Protocol\n \n from app.modules.addons.domain.value_objects import (\n     CatalogResult,\n     StreamResult,\n     StremioCatalogId,\n     StremioContentType,\n     StremioManifest,\n     StremioMetaItem,\n )\n \n \n class AddonProvider(Protocol):\n     \"\"\"Port for providing addon data — manifest, catalog, meta, and streams.\"\"\"\n \n     async def get_manifest(self) -> StremioManifest:\n         \"\"\"Return the addon's manifest descriptor.\"\"\"\n         ...\n \n     async def get_catalog(\n         self,\n         catalog_id: StremioCatalogId | str,\n         content_type: StremioContentType,\n         skip: int = 0,\n         limit: int = 20,\n     ) -> CatalogResult:\n         \"\"\"Get catalog listing for the given catalog ID and content type.\"\"\"\n         ...\n \n     async def get_meta(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n     ) -> StremioMetaItem | None:\n         \"\"\"Get full metadata for a specific content item.\"\"\"\n         ...\n \n     async def get_streams(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n+        api_key: str | None = None,\n     ) -> StreamResult:\n         \"\"\"Get available streams for a specific content item.\n \n         Returns both direct URLs and info-hash-based streams\n         that can be resolved through debrid services.\n         \"\"\"\n         ...\n+\n+\n+@dataclass(frozen=True)\n+class TorrentSearchResult:\n+    \"\"\"Result from a torrent indexer search.\"\"\"\n+\n+    imdb_id: str | None = None\n+    title: str = \"\"\n+    magnet_url: str = \"\"\n+    info_hash: str = \"\"\n+    seeds: int = 0\n+    size: int = 0\n+    source: str = \"\"\n+\n+\n+class TorrentSearchPort(Protocol):\n+    \"\"\"Port for searching torrent indexers (e.g. Prowlarr/Torznab).\"\"\"\n+\n+    async def search_by_imdb_id(self, imdb_id: str) -> list[TorrentSearchResult]:\n+        \"\"\"Search by IMDB ID (tt-prefixed) and return matching magnet links.\"\"\"\n+        ...\n+\n+    async def search_by_title(self, title: str, year: int | None = None) -> list[TorrentSearchResult]:\n+        \"\"\"Search by title string (fallback) and return matching magnet links.\"\"\"\n+        ...\n",
    "additions": 27,
    "deletions": 0,
    "status": "modified"
  },
  {
    "file": "app/modules/addons/application/stremio_stream_usecase.py",
    "patch": "Index: app/modules/addons/application/stremio_stream_usecase.py\n===================================================================\n--- app/modules/addons/application/stremio_stream_usecase.py\t\n+++ app/modules/addons/application/stremio_stream_usecase.py\t\n@@ -0,0 +1,182 @@\n+\"\"\"Stremio stream resolution use case.\n+\n+Bridges the Stremio addon protocol to the debrid streaming pipeline:\n+  1. Accept api_key + content_type + item_id from Stremio\n+  2. Search Prowlarr (via TorrentSearchPort) for matching magnets\n+  3. Resolve each magnet through ProviderStreamResolver\n+  4. Map results to StremioStreamInfo for the addon response\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from collections.abc import Mapping\n+\n+from app.core.logging import get_logger\n+from app.modules.addons.application.interfaces import TorrentSearchPort\n+from app.modules.addons.domain.value_objects import (\n+    StremioContentType,\n+    StremioStreamInfo,\n+)\n+from app.modules.streaming.application.interfaces import ProviderStreamResolver\n+from app.modules.streaming.domain.value_objects import (\n+    ResolveSource,\n+    ResolveSourceType,\n+    StreamQuality,\n+)\n+\n+logger = get_logger(__name__)\n+\n+_QUALITY_PRIORITY: dict[StreamQuality, int] = {\n+    StreamQuality.UHD: 6,\n+    StreamQuality.QHD: 5,\n+    StreamQuality.FHD: 4,\n+    StreamQuality.HD: 3,\n+    StreamQuality.SD: 2,\n+    StreamQuality.UNKNOWN: 1,\n+}\n+\n+_MAX_STREAMS = 20\n+\n+\n+class StremioStreamUseCase:\n+    \"\"\"Orchestrates stream resolution for Stremio addon requests.\n+\n+    Accepts a raw debrid API key (Stremio is anonymous), searches for\n+    torrents matching the requested content, and resolves them through\n+    the configured debrid provider.\n+    \"\"\"\n+\n+    def __init__(\n+        self,\n+        torrent_search: TorrentSearchPort,\n+        resolvers: Mapping[str, ProviderStreamResolver],\n+    ) -> None:\n+        self._torrent_search = torrent_search\n+        self._resolvers = resolvers\n+\n+    async def get_streams(\n+        self,\n+        api_key: str,\n+        content_type: StremioContentType,\n+        item_id: str,\n+    ) -> list[StremioStreamInfo]:\n+        \"\"\"Resolve streams for a Stremio content item using a raw API key.\n+\n+        Args:\n+            api_key: Raw debrid provider API key (from Stremio request).\n+            content_type: Movie or series enum.\n+            item_id: Content ID (tt-prefixed IMDB or tmdb:NNNN format).\n+\n+        Returns:\n+            Sorted list of StremioStreamInfo, best quality/cached first.\n+        \"\"\"\n+        imdb_id = _extract_imdb_id(item_id)\n+\n+        torrents = await self._search_torrents(imdb_id, item_id)\n+\n+        if not torrents:\n+            return []\n+\n+        resolved = await self._resolve_torrents(torrents, api_key)\n+\n+        results = [\n+            _map_to_stremio_stream(r)\n+            for r in resolved\n+        ]\n+\n+        results.sort(key=_stream_sort_key, reverse=True)\n+        return results[:_MAX_STREAMS]\n+\n+    async def _search_torrents(\n+        self,\n+        imdb_id: str | None,\n+        item_id: str,\n+    ) -> list[ResolveSource]:\n+        \"\"\"Search for torrents matching the content item.\"\"\"\n+        magnets: list[str] = []\n+\n+        if imdb_id:\n+            search_results = await self._torrent_search.search_by_imdb_id(imdb_id)\n+            magnets = [r.magnet_url for r in search_results if r.magnet_url]\n+\n+        if not magnets:\n+            search_results = await self._torrent_search.search_by_title(item_id)\n+            magnets = [r.magnet_url for r in search_results if r.magnet_url]\n+\n+        sources: list[ResolveSource] = []\n+        for magnet_url in magnets:\n+            try:\n+                source = ResolveSource.from_string(magnet_url)\n+                sources.append(source)\n+            except ValueError:\n+                continue\n+\n+        return sources\n+\n+    async def _resolve_torrents(\n+        self,\n+        sources: list[ResolveSource],\n+        api_key: str,\n+    ) -> list:\n+        \"\"\"Resolve each magnet through the first available resolver.\"\"\"\n+        all_results: list = []\n+        for source in sources:\n+            for resolver in self._resolvers.values():\n+                resolved = await resolver.resolve(source, api_key)\n+                all_results.extend(resolved)\n+                break\n+        return all_results\n+\n+\n+def _extract_imdb_id(item_id: str) -> str | None:\n+    \"\"\"Extract an IMDB ID (tt-prefixed) from a Stremio item ID.\n+\n+    Accepts raw IMDB IDs like ``tt0137523`` or IDs embedded in\n+    longer strings. Returns None if no IMDB ID is found.\n+    \"\"\"\n+    if item_id.startswith(\"tt\"):\n+        # Extract the first tt-prefixed token\n+        parts = item_id.split()\n+        for part in parts:\n+            stripped = part.strip()\n+            if stripped.startswith(\"tt\") and len(stripped) >= 8:\n+                candidate = stripped[:15]\n+                if candidate[2:].isdigit():\n+                    return candidate\n+    return None\n+\n+\n+def _map_to_stremio_stream(\n+    result,\n+) -> StremioStreamInfo:\n+    \"\"\"Map a StreamResolveResult to a StremioStreamInfo.\"\"\"\n+    quality_str = result.quality.value if hasattr(result.quality, \"value\") else str(result.quality)\n+    return StremioStreamInfo(\n+        url=result.stream_url,\n+        info_hash=None,\n+        file_index=None,\n+        name=result.title or \"Stream\",\n+        description=None,\n+        behavior_hints={\n+            \"notWebReady\": False,\n+            \"cached\": result.cached if hasattr(result, \"cached\") else False,\n+        },\n+        external_url=None,\n+        youtube_id=None,\n+        subtitles=[],\n+        source=result.provider if hasattr(result, \"provider\") else None,\n+        quality=quality_str,\n+    )\n+\n+\n+def _stream_sort_key(stream: StremioStreamInfo) -> int:\n+    \"\"\"Sort key: higher = better placement.\"\"\"\n+    priority = 0\n+    if stream.behavior_hints:\n+        cached_score = 2 if stream.behavior_hints.get(\"cached\") else 0\n+    else:\n+        cached_score = 0\n+    quality_score = 0\n+    if stream.quality:\n+        quality_score = _QUALITY_PRIORITY.get(StreamQuality(stream.quality), 0)\n+    return quality_score + cached_score\n",
    "additions": 182,
    "deletions": 0,
    "status": "added"
  },
  {
    "file": "app/modules/addons/application/use_cases.py",
    "patch": "Index: app/modules/addons/application/use_cases.py\n===================================================================\n--- app/modules/addons/application/use_cases.py\t\n+++ app/modules/addons/application/use_cases.py\t\n@@ -1,110 +1,112 @@\n \"\"\"Addons module application use cases.\n \n Coordinates between the AddonProvider port and domain entities\n to serve the Stremio addon protocol.\n \"\"\"\n \n from __future__ import annotations\n \n from contextlib import suppress\n \n from app.modules.addons.application.exceptions import (\n     AddonNotFoundError,\n     CatalogNotFoundError,\n     MetaNotFoundError,\n     StreamResolutionError,\n )\n from app.modules.addons.application.interfaces import AddonProvider\n from app.modules.addons.domain.value_objects import (\n     CatalogResult,\n     StremioCatalogId,\n     StremioContentType,\n     StremioManifest,\n     StremioMetaItem,\n     StremioStreamInfo,\n )\n \n \n class AddonUseCases:\n     \"\"\"Application-layer use cases for addon protocol operations.\"\"\"\n \n     def __init__(self, provider: AddonProvider) -> None:\n         self._provider = provider\n \n     async def get_manifest(self) -> StremioManifest:\n         \"\"\"Get the Stremio addon manifest.\"\"\"\n         try:\n             return await self._provider.get_manifest()\n         except Exception:\n             raise AddonNotFoundError(\"Failed to generate addon manifest\") from None\n \n     async def get_catalog(\n         self,\n         catalog_id: str,\n         content_type: StremioContentType,\n         skip: int = 0,\n         limit: int = 20,\n     ) -> CatalogResult:\n         \"\"\"Get catalog entries for the given catalog ID.\"\"\"\n         skip = max(0, skip)\n         limit = max(1, min(limit, 100))\n \n         try:\n             # Accept both enum values and raw string catalog IDs\n             parsed_id: StremioCatalogId | str = catalog_id\n             # Try to match known catalog IDs\n             with suppress(ValueError):\n                 parsed_id = StremioCatalogId(catalog_id)\n \n             return await self._provider.get_catalog(\n                 catalog_id=parsed_id,\n                 content_type=content_type,\n                 skip=skip,\n                 limit=limit,\n             )\n         except CatalogNotFoundError:\n             raise\n         except Exception:\n             raise CatalogNotFoundError(f\"Catalog not found: {catalog_id}\") from None\n \n     async def get_meta(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n     ) -> StremioMetaItem:\n         \"\"\"Get full metadata for a specific content item.\"\"\"\n         try:\n             meta = await self._provider.get_meta(\n                 content_type=content_type,\n                 item_id=item_id,\n             )\n             if meta is None:\n                 raise MetaNotFoundError(\n                     f\"Metadata not found: {content_type.value}/{item_id}\"\n                 )\n             return meta\n         except MetaNotFoundError:\n             raise\n         except Exception:\n             raise MetaNotFoundError(\n                 f\"Failed to retrieve metadata for {content_type.value}/{item_id}\"\n             ) from None\n \n     async def get_streams(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n+        api_key: str | None = None,\n     ) -> list[StremioStreamInfo]:\n         \"\"\"Get available streams for a specific content item.\"\"\"\n         try:\n             result = await self._provider.get_streams(\n                 content_type=content_type,\n                 item_id=item_id,\n+                api_key=api_key,\n             )\n             return result.streams\n         except StreamResolutionError:\n             raise\n         except Exception:\n             raise StreamResolutionError(\n                 f\"Failed to resolve streams for {content_type.value}/{item_id}\"\n             ) from None\n",
    "additions": 2,
    "deletions": 0,
    "status": "modified"
  },
  {
    "file": "app/modules/addons/infrastructure/addon_provider.py",
    "patch": "Index: app/modules/addons/infrastructure/addon_provider.py\n===================================================================\n--- app/modules/addons/infrastructure/addon_provider.py\t\n+++ app/modules/addons/infrastructure/addon_provider.py\t\n@@ -1,281 +1,307 @@\n \"\"\"Addon provider implementation bridging metadata and debrid streaming.\n \n Implements the AddonProvider port by:\n   - Generating Stremio-compatible manifests\n   - Querying TMDB (via metadata module) for catalog and meta entries\n   - Providing stream information through the debrid streaming pipeline\n \"\"\"\n \n from __future__ import annotations\n \n from app.core.logging import get_logger\n from app.modules.addons.application.exceptions import (\n     CatalogNotFoundError,\n     MetaNotFoundError,\n+    StreamResolutionError,\n )\n from app.modules.addons.application.interfaces import AddonProvider\n+from app.modules.addons.application.stremio_stream_usecase import StremioStreamUseCase\n from app.modules.addons.domain.value_objects import (\n     CatalogResult,\n     StreamResult,\n     StremioCatalogId,\n     StremioCatalogItem,\n     StremioContentType,\n     StremioManifest,\n     StremioMetaItem,\n     StremioResourceType,\n     StremioStreamInfo,\n )\n from app.modules.metadata.application.interfaces import MetadataProvider\n from app.modules.metadata.domain.value_objects import (\n     MediaItem,\n     MediaType,\n     TMDBId,\n     TrendingWindow,\n )\n \n logger = get_logger(__name__)\n \n ADDON_ID = \"com.streamingapp.addon\"\n ADDON_VERSION = \"1.0.0\"\n ADDON_NAME = \"Streaming App Addon\"\n ADDON_DESCRIPTION = (\n     \"Debrid-resolved streams for movies and series, \"\n     \"powered by TMDB metadata.\"\n )\n \n \n class _StreamingAppAddonProvider(AddonProvider):\n     \"\"\"Addon provider backed by the app's metadata and debrid infrastructure.\n \n     Uses the MetadataProvider to power catalog and meta resources,\n-    and delegates stream resolution to the debrid streaming subsystem.\n+    and delegates stream resolution to the StremioStreamUseCase.\n     \"\"\"\n \n-    def __init__(self, metadata_provider: MetadataProvider | None = None) -> None:\n+    def __init__(\n+        self,\n+        metadata_provider: MetadataProvider | None = None,\n+        stream_usecase: StremioStreamUseCase | None = None,\n+    ) -> None:\n         self._metadata_provider = metadata_provider\n+        self._stream_usecase = stream_usecase\n \n     async def get_manifest(self) -> StremioManifest:\n         \"\"\"Generate the Stremio-compatible addon manifest.\"\"\"\n         catalogs = [\n             {\"type\": \"movie\", \"id\": StremioCatalogId.TMDB_TRENDING_MOVIES.value},\n             {\"type\": \"series\", \"id\": StremioCatalogId.TMDB_TRENDING_SERIES.value},\n         ]\n         return StremioManifest(\n             id=ADDON_ID,\n             version=ADDON_VERSION,\n             name=ADDON_NAME,\n             description=ADDON_DESCRIPTION,\n             resources=[\n                 StremioResourceType.CATALOG,\n                 StremioResourceType.META,\n                 StremioResourceType.STREAM,\n             ],\n             types=[StremioContentType.MOVIE, StremioContentType.SERIES],\n             id_prefixes=[\"tt\"],\n             catalogs=catalogs,\n             icon=None,\n             background=None,\n         )\n \n     async def get_catalog(\n         self,\n         catalog_id: StremioCatalogId | str,\n         content_type: StremioContentType,\n         skip: int = 0,\n         limit: int = 20,\n     ) -> CatalogResult:\n         \"\"\"Get catalog entries, backed by TMDB trending queries.\"\"\"\n         if self._metadata_provider is None:\n             raise CatalogNotFoundError(\"Metadata provider is not configured\")\n \n         # Map Stremio content type to metadata MediaType\n         media_type = (\n             MediaType.MOVIE if content_type == StremioContentType.MOVIE\n             else MediaType.TV\n         )\n \n         page = (skip // limit) + 1 if limit > 0 else 1\n \n         try:\n             trending_window = TrendingWindow.WEEK\n             result = await self._metadata_provider.get_trending(\n                 media_type=media_type,\n                 window=trending_window,\n                 page=page,\n             )\n \n             items = [\n                 self._map_to_catalog_item(item, content_type)\n                 for item in result.items\n             ]\n             return CatalogResult(items=items, total=result.total_results)\n         except Exception as exc:\n             logger.exception(\n                 \"addon_catalog_failed\",\n                 catalog_id=str(catalog_id),\n                 content_type=content_type.value,\n             )\n             raise CatalogNotFoundError(\n                 f\"Catalog unavailable: {catalog_id}\"\n             ) from exc\n \n     async def get_meta(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n     ) -> StremioMetaItem | None:\n         \"\"\"Get full metadata for a content item from TMDB.\n \n         Expects item_id in the format ``tt1234567`` (IMDb ID) or\n         ``tmdb:12345`` (TMDB ID). Falls back to IMDb ID lookup.\n         \"\"\"\n         if self._metadata_provider is None:\n             raise MetaNotFoundError(\"Metadata provider is not configured\")\n \n         media_type = (\n             MediaType.MOVIE if content_type == StremioContentType.MOVIE\n             else MediaType.TV\n         )\n \n         # Parse the item ID — try TMDB ID first\n         tmdb_id = self._parse_item_id(item_id)\n         if tmdb_id is None:\n             raise MetaNotFoundError(\n                 f\"Cannot parse item ID: {item_id}. \"\n                 \"Expected format: tmdb:<id> or a numeric TMDB ID.\"\n             )\n \n         try:\n             meta = await self._metadata_provider.get_details(\n                 tmdb_id=tmdb_id,\n                 media_type=media_type,\n             )\n             if meta is None:\n                 return None\n \n             return self._map_to_meta_item(meta, content_type)\n         except Exception as exc:\n             logger.exception(\n                 \"addon_meta_failed\",\n                 content_type=content_type.value,\n                 item_id=item_id,\n             )\n             raise MetaNotFoundError(\n                 f\"Metadata unavailable for {content_type.value}/{item_id}\"\n             ) from exc\n \n     async def get_streams(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n+        api_key: str | None = None,\n     ) -> StreamResult:\n         \"\"\"Resolve streams for a content item.\n \n-        For now, returns a placeholder stream entry indicating the\n-        stream resolution service. In production, this bridges to\n-        the debrid streaming pipeline to search for and resolve\n-        torrents matching the requested content.\n+        Uses StremioStreamUseCase to search for matching torrents\n+        and resolve them through the debrid provider. Falls back\n+        to a placeholder response when no use case is configured\n+        or no API key is provided.\n         \"\"\"\n+        if self._stream_usecase is not None and api_key:\n+            try:\n+                streams = await self._stream_usecase.get_streams(\n+                    api_key=api_key,\n+                    content_type=content_type,\n+                    item_id=item_id,\n+                )\n+                return StreamResult(streams=streams)\n+            except Exception:\n+                logger.exception(\n+                    \"stream_resolution_failed\",\n+                    content_type=content_type.value,\n+                    item_id=item_id,\n+                )\n+                raise StreamResolutionError(\n+                    f\"Failed to resolve streams for {content_type.value}/{item_id}\"\n+                ) from None\n+\n         return StreamResult(\n             streams=[\n                 StremioStreamInfo(\n                     name=f\"{ADDON_NAME} • Stream\",\n                     description=(\n                         f\"Resolved stream for {content_type.value}: {item_id}. \"\n                         \"Connect a debrid provider to enable real streams.\"\n                     ),\n                     behavior_hints={\n                         \"notWebReady\": True,\n                         \"bingeGroup\": f\"streaming-app-{item_id}\",\n                     },\n                 ),\n             ],\n         )\n \n     # --- Private helpers ---\n \n     @staticmethod\n     def _parse_item_id(item_id: str) -> TMDBId | None:\n         \"\"\"Parse a Stremio-style item ID into a TMDBId.\n \n         Accepts:\n           - ``tmdb:550``  (explicit TMDB prefix)\n           - ``550``       (raw numeric TMDB ID)\n           - ``tt0137523`` (IMDb ID — currently not supported for lookup)\n         \"\"\"\n         if item_id.startswith(\"tmdb:\"):\n             raw = item_id[5:]\n             try:\n                 return TMDBId(int(raw))\n             except (ValueError, TypeError):\n                 return None\n \n         # Try raw numeric (TMDB ID)\n         try:\n             return TMDBId(int(item_id))\n         except (ValueError, TypeError):\n             pass\n \n         # IMDb IDs (tt-prefixed) — requires an IMDb→TMDB lookup\n         # which is not yet implemented; fall through.\n         return None\n \n     @staticmethod\n     def _map_to_catalog_item(\n         item: MediaItem,\n         content_type: StremioContentType,\n     ) -> StremioCatalogItem:\n         \"\"\"Map a MediaItem domain entity to a Stremio catalog entry.\"\"\"\n         return StremioCatalogItem(\n             id=f\"tmdb:{item.tmdb_id.value}\",\n             type=content_type,\n             name=item.title,\n             poster=(\n                 f\"https://image.tmdb.org/t/p/w500{item.poster_path}\"\n                 if item.poster_path else None\n             ),\n             poster_shape=\"poster\",\n             background=(\n                 f\"https://image.tmdb.org/t/p/original{item.backdrop_path}\"\n                 if item.backdrop_path else None\n             ),\n             description=item.overview,\n             release_info=(\n                 item.release_date[:4] if item.release_date else None\n             ),\n             runtime=f\"{item.runtime} min\" if item.runtime else None,\n             genre=item.genres if isinstance(item.genres, list) and item.genres\n             else [],\n             imdb_rating=str(item.vote_average) if item.vote_average else None,\n             popularity=item.popularity,\n         )\n \n     @staticmethod\n     def _map_to_meta_item(\n         item: MediaItem,\n         content_type: StremioContentType,\n     ) -> StremioMetaItem:\n         \"\"\"Map a MediaItem domain entity to a Stremio meta response.\"\"\"\n         return StremioMetaItem(\n             id=f\"tmdb:{item.tmdb_id.value}\",\n             type=content_type,\n             name=item.title,\n             poster=(\n                 f\"https://image.tmdb.org/t/p/w500{item.poster_path}\"\n                 if item.poster_path else None\n             ),\n             poster_shape=\"poster\",\n             background=(\n                 f\"https://image.tmdb.org/t/p/original{item.backdrop_path}\"\n                 if item.backdrop_path else None\n             ),\n             description=item.overview,\n             release_info=(\n                 item.release_date[:4] if item.release_date else None\n             ),\n             runtime=f\"{item.runtime} min\" if item.runtime else None,\n             genre=item.genres if isinstance(item.genres, list) and item.genres\n             else [],\n             imdb_rating=str(item.vote_average) if item.vote_average else None,\n             popularity=item.popularity,\n         )\n",
    "additions": 32,
    "deletions": 6,
    "status": "modified"
  },
  {
    "file": "app/modules/addons/infrastructure/prowlarr_client.py",
    "patch": "Index: app/modules/addons/infrastructure/prowlarr_client.py\n===================================================================\n--- app/modules/addons/infrastructure/prowlarr_client.py\t\n+++ app/modules/addons/infrastructure/prowlarr_client.py\t\n@@ -0,0 +1,199 @@\n+\"\"\"Prowlarr Torznab API client.\n+\n+Connects to Prowlarr's Torznab-compatible endpoint to search for\n+torrents by IMDB ID or title string, returning magnet links.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import xml.etree.ElementTree as ET\n+from dataclasses import dataclass\n+\n+import httpx\n+\n+from app.core.config import get_settings\n+from app.core.logging import get_logger\n+from app.modules.addons.application.interfaces import TorrentSearchPort, TorrentSearchResult\n+\n+logger = get_logger(__name__)\n+\n+_DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=15.0, write=5.0, pool=2.0)\n+_DEFAULT_LIMITS = httpx.Limits(max_connections=50, max_keepalive_connections=10)\n+\n+_RETRYABLE_STATUS_CODES = {502, 503, 504}\n+_MAX_RESULTS = 50\n+\n+\n+@dataclass\n+class ProwlarrConfig:\n+    \"\"\"Configuration for the Prowlarr Torznab client.\"\"\"\n+\n+    url: str = \"\"\n+    api_key: str = \"\"\n+\n+\n+def _load_config() -> ProwlarrConfig:\n+    \"\"\"Load Prowlarr config from application settings.\n+\n+    Reads PROWLARR_URL and PROWLARR_API_KEY from environment.\n+    Returns an empty config if not set (fail-open for local dev).\n+    \"\"\"\n+    settings = get_settings()\n+    return ProwlarrConfig(url=settings.PROWLARR_URL, api_key=settings.PROWLARR_API_KEY)\n+\n+\n+class ProwlarrClient(TorrentSearchPort):\n+    \"\"\"Torrent indexer search client backed by Prowlarr's Torznab API.\"\"\"\n+\n+    def __init__(\n+        self,\n+        config: ProwlarrConfig | None = None,\n+        http_client: httpx.AsyncClient | None = None,\n+    ) -> None:\n+        self._config = config or _load_config()\n+        self._owns_client = http_client is None\n+        self._client = http_client or httpx.AsyncClient(\n+            timeout=_DEFAULT_TIMEOUT,\n+            limits=_DEFAULT_LIMITS,\n+        )\n+\n+    async def close(self) -> None:\n+        \"\"\"Close the underlying HTTP client if owned by this instance.\"\"\"\n+        if self._owns_client:\n+            await self._client.aclose()\n+\n+    async def search_by_imdb_id(self, imdb_id: str) -> list[TorrentSearchResult]:\n+        \"\"\"Search for torrents by IMDB ID (tt-prefixed).\"\"\"\n+        if not self._config.url or not self._config.api_key:\n+            logger.warning(\"prowlarr_not_configured\", imdb_id=imdb_id)\n+            return []\n+\n+        params: dict[str, str] = {\n+            \"apikey\": self._config.api_key,\n+            \"t\": \"search\",\n+            \"imdbid\": imdb_id,\n+            \"extended\": \"1\",\n+        }\n+\n+        try:\n+            raw = await self._request(params)\n+            return self._parse_response(raw)\n+        except Exception:\n+            logger.exception(\"prowlarr_search_failed\", imdb_id=imdb_id)\n+            return []\n+\n+    async def search_by_title(self, title: str, year: int | None = None) -> list[TorrentSearchResult]:\n+        \"\"\"Search for torrents by title string, with optional year filter.\"\"\"\n+        if not self._config.url or not self._config.api_key:\n+            logger.warning(\"prowlarr_not_configured\", title=title)\n+            return []\n+\n+        query = title\n+        if year is not None:\n+            query = f\"{title} {year}\"\n+\n+        params: dict[str, str] = {\n+            \"apikey\": self._config.api_key,\n+            \"t\": \"search\",\n+            \"q\": query,\n+            \"extended\": \"1\",\n+        }\n+\n+        try:\n+            raw = await self._request(params)\n+            return self._parse_response(raw)\n+        except Exception:\n+            logger.exception(\"prowlarr_search_failed\", title=title)\n+            return []\n+\n+    async def _request(self, params: dict[str, str]) -> str:\n+        \"\"\"Make a request to the Prowlarr Torznab API and return raw XML text.\"\"\"\n+        url = self._config.url.rstrip(\"/\") + \"/api\"\n+\n+        last_exc: Exception | None = None\n+        for attempt in range(2):\n+            try:\n+                response = await self._client.get(url, params=params)\n+            except (httpx.TimeoutException, httpx.RequestError) as exc:\n+                last_exc = exc\n+                continue\n+\n+            if response.status_code in _RETRYABLE_STATUS_CODES and attempt == 0:\n+                continue\n+\n+            response.raise_for_status()\n+            return response.text\n+\n+        raise last_exc or httpx.RequestError(\"Prowlarr request failed\")\n+\n+    @staticmethod\n+    def _parse_response(raw_xml: str) -> list[TorrentSearchResult]:\n+        \"\"\"Parse a Torznab XML response into TorrentSearchResult list.\"\"\"\n+        results: list[TorrentSearchResult] = []\n+        try:\n+            root = ET.fromstring(raw_xml)\n+        except ET.ParseError:\n+            logger.exception(\"prowlarr_parse_failed\")\n+            return []\n+\n+        for item in root.iter(\"item\"):\n+            result = _parse_torznab_item(item)\n+            if result and result.magnet_url:\n+                results.append(result)\n+                if len(results) >= _MAX_RESULTS:\n+                    break\n+\n+        results.sort(key=lambda r: r.seeds, reverse=True)\n+        return results\n+\n+\n+def _parse_torznab_item(item: ET.Element) -> TorrentSearchResult | None:\n+    \"\"\"Parse a single Torznab <item> element into a TorrentSearchResult.\"\"\"\n+    title = \"\"\n+    magnet_url = \"\"\n+    info_hash = \"\"\n+    seeds = 0\n+    size = 0\n+    imdb_id: str | None = None\n+\n+    title_el = item.find(\"title\")\n+    if title_el is not None and title_el.text:\n+        title = title_el.text.strip()\n+\n+    size_el = item.find(\"size\")\n+    if size_el is not None and size_el.text:\n+        try:\n+            size = int(size_el.text)\n+        except (ValueError, TypeError):\n+            pass\n+\n+    for link_el in item.iter(\"link\"):\n+        if link_el.text and link_el.text.startswith(\"magnet:\"):\n+            magnet_url = link_el.text.strip()\n+            break\n+\n+    for attr_el in item.iter(\"attr\"):\n+        name = (attr_el.get(\"name\") or \"\").lower()\n+        value = attr_el.get(\"value\") or \"\"\n+        if name == \"seeders\":\n+            try:\n+                seeds = int(value)\n+            except (ValueError, TypeError):\n+                pass\n+        elif name == \"infohash\":\n+            info_hash = value.strip()\n+        elif name == \"imdbid\":\n+            imdb_id = value.strip() or None\n+\n+    if not magnet_url:\n+        return None\n+\n+    return TorrentSearchResult(\n+        imdb_id=imdb_id,\n+        title=title,\n+        magnet_url=magnet_url,\n+        info_hash=info_hash,\n+        seeds=seeds,\n+        size=size,\n+        source=\"prowlarr\",\n+    )\n",
    "additions": 199,
    "deletions": 0,
    "status": "added"
  },
  {
    "file": "app/modules/addons/presentation/dependencies.py",
    "patch": "Index: app/modules/addons/presentation/dependencies.py\n===================================================================\n--- app/modules/addons/presentation/dependencies.py\t\n+++ app/modules/addons/presentation/dependencies.py\t\n@@ -1,56 +1,85 @@\n \"\"\"Addon module FastAPI dependencies.\"\"\"\n \n from __future__ import annotations\n \n from typing import Annotated\n \n-from fastapi import Depends\n+from fastapi import Depends, Request\n \n from app.core.logging import get_logger\n from app.modules.addons.application.interfaces import AddonProvider\n+from app.modules.addons.application.stremio_stream_usecase import StremioStreamUseCase\n from app.modules.addons.application.use_cases import AddonUseCases\n from app.modules.addons.infrastructure.addon_provider import (\n     _StreamingAppAddonProvider,\n )\n+from app.modules.addons.infrastructure.prowlarr_client import ProwlarrClient\n+from app.modules.streaming.presentation.dependencies import (\n+    ensure_stream_resolvers,\n+)\n \n logger = get_logger(__name__)\n \n-# --- Addon provider singleton ---\n+# --- Singletons ---\n \n _ADDON_PROVIDER: AddonProvider | None = None\n+_PROWlARR_CLIENT: ProwlarrClient | None = None\n \n \n+def _build_prowlarr_client() -> ProwlarrClient:\n+    \"\"\"Build the Prowlarr client singleton.\"\"\"\n+    global _PROWlARR_CLIENT\n+    if _PROWlARR_CLIENT is None:\n+        _PROWlARR_CLIENT = ProwlarrClient()\n+    return _PROWlARR_CLIENT\n+\n+\n def _build_addon_provider() -> AddonProvider:\n-    \"\"\"Build the addon provider singleton.\"\"\"\n-    return _StreamingAppAddonProvider()\n+    \"\"\"Build the addon provider singleton with wired dependencies.\"\"\"\n+    prowlarr = _build_prowlarr_client()\n+    resolvers = ensure_stream_resolvers()\n+    stream_usecase = StremioStreamUseCase(\n+        torrent_search=prowlarr,\n+        resolvers=resolvers,\n+    )\n+    return _StreamingAppAddonProvider(stream_usecase=stream_usecase)\n \n \n def get_addon_provider() -> AddonProvider:\n     \"\"\"Return the addon provider singleton.\"\"\"\n     global _ADDON_PROVIDER\n     if _ADDON_PROVIDER is None:\n         _ADDON_PROVIDER = _build_addon_provider()\n     return _ADDON_PROVIDER\n \n \n async def close_addon_provider(provider: AddonProvider | None = None) -> None:\n-    \"\"\"Close the addon provider (no-op for now, reserved for cleanup).\"\"\"\n-    global _ADDON_PROVIDER\n+    \"\"\"Close the addon provider and its dependencies.\"\"\"\n+    global _ADDON_PROVIDER, _PROWlARR_CLIENT\n     target = provider or _ADDON_PROVIDER\n     if target and hasattr(target, \"close\"):\n         try:\n             await target.close()\n         except Exception:\n             logger.exception(\"addon_provider_close_failed\")\n     if provider is None:\n         _ADDON_PROVIDER = None\n \n+    prowlarr = _PROWlARR_CLIENT\n+    if prowlarr is not None:\n+        try:\n+            await prowlarr.close()\n+        except Exception:\n+            logger.exception(\"prowlarr_client_close_failed\")\n+    if provider is None:\n+        _PROWlARR_CLIENT = None\n \n+\n # --- Use case dependency ---\n \n \n def get_addon_use_cases(\n     provider: Annotated[AddonProvider, Depends(get_addon_provider)],\n ) -> AddonUseCases:\n     \"\"\"Provide AddonUseCases with injected ports.\"\"\"\n     return AddonUseCases(provider=provider)\n",
    "additions": 35,
    "deletions": 6,
    "status": "modified"
  },
  {
    "file": "app/modules/addons/presentation/routers.py",
    "patch": "Index: app/modules/addons/presentation/routers.py\n===================================================================\n--- app/modules/addons/presentation/routers.py\t\n+++ app/modules/addons/presentation/routers.py\t\n@@ -1,298 +1,301 @@\n \"\"\"Stremio-compatible addon protocol API routes.\n \n Provides endpoints compatible with the Stremio addon protocol:\n   - ``/stremio/manifest.json``     → addon manifest\n   - ``/stremio/catalog/{type}/{id}.json`` → catalog listing\n   - ``/stremio/meta/{type}/{id}.json`` → item metadata\n   - ``/stremio/stream/{type}/{id}.json`` → stream URLs\n \n See: https://github.com/Stremio/stremio-addon-sdk\n \"\"\"\n \n from __future__ import annotations\n \n from typing import Annotated\n \n from fastapi import APIRouter, Depends, HTTPException, status\n \n from app.core.logging import get_logger\n from app.modules.addons.application.exceptions import (\n     AddonError,\n     CatalogNotFoundError,\n     MetaNotFoundError,\n     StreamResolutionError,\n )\n from app.modules.addons.application.use_cases import AddonUseCases\n from app.modules.addons.domain.value_objects import (\n     StremioCatalogItem,\n     StremioContentType,\n     StremioMetaItem,\n     StremioStreamInfo,\n )\n from app.modules.addons.presentation.dependencies import get_addon_use_cases\n from app.modules.addons.presentation.schemas import (\n     CatalogItemResponse,\n     CatalogResponse,\n     ManifestResponse,\n     MetaResponse,\n     StreamInfoResponse,\n     StreamResponse,\n )\n \n logger = get_logger(__name__)\n router = APIRouter(prefix=\"/stremio\", tags=[\"stremio\"])\n \n UseCases = Annotated[AddonUseCases, Depends(get_addon_use_cases)]\n \n \n # --- Response mapping helpers ---\n \n \n def _catalog_item_response(item: StremioCatalogItem) -> CatalogItemResponse:\n     \"\"\"Map a domain StremioCatalogItem to a Pydantic schema.\"\"\"\n     return CatalogItemResponse(\n         id=item.id,\n         type=item.type.value,\n         name=item.name,\n         poster=item.poster,\n         posterShape=item.poster_shape,\n         background=item.background,\n         logo=item.logo,\n         description=item.description,\n         releaseInfo=item.release_info,\n         runtime=item.runtime,\n         genre=list(item.genre) if item.genre else [],\n         imdbRating=item.imdb_rating,\n         popularity=item.popularity,\n     )\n \n \n def _meta_item_response(item: StremioMetaItem) -> CatalogItemResponse:\n     \"\"\"Map a domain StremioMetaItem to a Pydantic schema.\n \n     Stremio uses the same CatalogItemResponse shape for meta responses\n     but with richer fields (cast, videos, etc. are in ``meta`` directly).\n     The Stremio client actually accepts extended fields on the meta object.\n     For production, this should use ``extra`` fields to include cast/videos.\n     \"\"\"\n     return CatalogItemResponse(\n         id=item.id,\n         type=item.type.value,\n         name=item.name,\n         poster=item.poster,\n         posterShape=item.poster_shape,\n         background=item.background,\n         logo=item.logo,\n         description=item.description,\n         releaseInfo=item.release_info,\n         runtime=item.runtime,\n         genre=list(item.genre) if item.genre else [],\n         imdbRating=item.imdb_rating,\n         popularity=item.popularity,\n     )\n \n \n def _stream_info_response(stream: StremioStreamInfo) -> StreamInfoResponse:\n     \"\"\"Map a domain StremioStreamInfo to a Pydantic schema.\"\"\"\n     return StreamInfoResponse(\n         url=stream.url,\n         infoHash=stream.info_hash,\n         fileIdx=stream.file_index,\n         name=stream.name,\n         description=stream.description,\n         behaviorHints=stream.behavior_hints,\n         externalUrl=stream.external_url,\n         ytId=stream.youtube_id,\n         subtitles=list(stream.subtitles) if stream.subtitles else [],\n         source=stream.source,\n         quality=stream.quality,\n     )\n \n \n # --- Error handler ---\n \n \n def _addon_error_handler(exc: AddonError) -> HTTPException:\n     \"\"\"Map addon exceptions to appropriate HTTP responses.\"\"\"\n     if isinstance(exc, CatalogNotFoundError):\n         return HTTPException(\n             status_code=status.HTTP_404_NOT_FOUND,\n             detail=exc.public_message,\n         )\n     if isinstance(exc, MetaNotFoundError):\n         return HTTPException(\n             status_code=status.HTTP_404_NOT_FOUND,\n             detail=exc.public_message,\n         )\n     if isinstance(exc, StreamResolutionError):\n         return HTTPException(\n             status_code=status.HTTP_404_NOT_FOUND,\n             detail=exc.public_message,\n         )\n     return HTTPException(\n         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n         detail=\"Addon error\",\n     )\n \n \n # --- Endpoints ---\n \n \n @router.get(\n     \"/manifest.json\",\n     response_model=ManifestResponse,\n     summary=\"Stremio addon manifest\",\n     description=\"Returns the addon manifest describing capabilities, \"\n     \"resources, and catalogs.\",\n )\n async def get_manifest(\n     use_cases: UseCases,\n ) -> ManifestResponse:\n     \"\"\"Get the Stremio-compatible addon manifest.\"\"\"\n     try:\n         manifest = await use_cases.get_manifest()\n         return ManifestResponse(\n             id=manifest.id,\n             version=manifest.version,\n             name=manifest.name,\n             description=manifest.description,\n             resources=[r.value for r in manifest.resources],\n             types=[t.value for t in manifest.types],\n             idPrefixes=list(manifest.id_prefixes),\n             catalogs=list(manifest.catalogs),\n             icon=manifest.icon,\n             background=manifest.background,\n         )\n     except AddonError as exc:\n         raise _addon_error_handler(exc) from exc\n     except Exception:\n         logger.exception(\"manifest_failed\")\n         raise HTTPException(\n             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n             detail=\"Failed to generate addon manifest\",\n         ) from None\n \n \n @router.get(\n     \"/catalog/{content_type}/{catalog_id}.json\",\n     response_model=CatalogResponse,\n     summary=\"Stremio catalog listing\",\n     description=\"Returns a catalog of movies or series.\",\n )\n async def get_catalog(\n     content_type: str,\n     catalog_id: str,\n     use_cases: UseCases,\n     skip: int = 0,\n     limit: int = 20,\n ) -> CatalogResponse:\n     \"\"\"Get a catalog listing of movies or series.\"\"\"\n     try:\n         parsed_type = StremioContentType(content_type)\n         result = await use_cases.get_catalog(\n             catalog_id=catalog_id,\n             content_type=parsed_type,\n             skip=max(0, skip),\n             limit=max(1, min(limit, 100)),\n         )\n         return CatalogResponse(\n             metas=[_catalog_item_response(item) for item in result.items],\n         )\n     except ValueError:\n         raise HTTPException(\n             status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,\n             detail=f\"Invalid content type: {content_type}\",\n         ) from None\n     except AddonError as exc:\n         raise _addon_error_handler(exc) from exc\n     except Exception:\n         logger.exception(\n             \"catalog_failed\",\n             content_type=content_type,\n             catalog_id=catalog_id,\n         )\n         raise HTTPException(\n             status_code=status.HTTP_502_BAD_GATEWAY,\n             detail=\"Catalog request failed\",\n         ) from None\n \n \n @router.get(\n     \"/meta/{content_type}/{item_id}.json\",\n     response_model=MetaResponse,\n     summary=\"Stremio metadata for a single item\",\n     description=\"Returns detailed metadata for a specific movie or series.\",\n )\n async def get_meta(\n     content_type: str,\n     item_id: str,\n     use_cases: UseCases,\n ) -> MetaResponse:\n     \"\"\"Get detailed metadata for a specific content item.\"\"\"\n     try:\n         parsed_type = StremioContentType(content_type)\n         meta = await use_cases.get_meta(\n             content_type=parsed_type,\n             item_id=item_id,\n         )\n         return MetaResponse(\n             meta=_meta_item_response(meta),\n         )\n     except ValueError:\n         raise HTTPException(\n             status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,\n             detail=f\"Invalid content type: {content_type}\",\n         ) from None\n     except AddonError as exc:\n         raise _addon_error_handler(exc) from exc\n     except Exception:\n         logger.exception(\n             \"meta_failed\",\n             content_type=content_type,\n             item_id=item_id,\n         )\n         raise HTTPException(\n             status_code=status.HTTP_502_BAD_GATEWAY,\n             detail=\"Metadata request failed\",\n         ) from None\n \n \n @router.get(\n     \"/stream/{content_type}/{item_id}.json\",\n     response_model=StreamResponse,\n     summary=\"Stremio stream URLs\",\n     description=\"Returns available streams (URLs or info-hashes) \"\n-    \"for a specific movie or series.\",\n+    \"for a specific movie or series. Accepts an optional api_key \"\n+    \"query parameter for debrid provider authentication.\",\n )\n async def get_streams(\n     content_type: str,\n     item_id: str,\n     use_cases: UseCases,\n+    api_key: str | None = None,\n ) -> StreamResponse:\n     \"\"\"Get available streams for a specific content item.\"\"\"\n     try:\n         parsed_type = StremioContentType(content_type)\n         streams = await use_cases.get_streams(\n             content_type=parsed_type,\n             item_id=item_id,\n+            api_key=api_key,\n         )\n         return StreamResponse(\n             streams=[_stream_info_response(s) for s in streams],\n         )\n     except ValueError:\n         raise HTTPException(\n             status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,\n             detail=f\"Invalid content type: {content_type}\",\n         ) from None\n     except AddonError as exc:\n         raise _addon_error_handler(exc) from exc\n     except Exception:\n         logger.exception(\n             \"streams_failed\",\n             content_type=content_type,\n             item_id=item_id,\n         )\n         raise HTTPException(\n             status_code=status.HTTP_502_BAD_GATEWAY,\n             detail=\"Stream request failed\",\n         ) from None\n",
    "additions": 4,
    "deletions": 1,
    "status": "modified"
  },
  {
    "file": "app/modules/streaming/application/use_cases.py",
    "patch": "Index: app/modules/streaming/application/use_cases.py\n===================================================================\n--- app/modules/streaming/application/use_cases.py\t\n+++ app/modules/streaming/application/use_cases.py\t\n@@ -1,205 +1,230 @@\n \"\"\"Streaming application use cases.\"\"\"\n \n+from __future__ import annotations\n+\n import asyncio\n from collections.abc import Mapping\n from contextlib import suppress\n from dataclasses import replace\n \n from app.modules.streaming.application.cache_policy import (\n     bounded_cache_ttl_seconds,\n     compute_cache_key,\n     default_cache_ttl_seconds,\n )\n from app.modules.streaming.application.exceptions import (\n     NoDebridAccountError,\n     ProviderStreamUnavailableError,\n     StreamResolveError,\n     UnsupportedStreamSourceError,\n )\n from app.modules.streaming.application.interfaces import (\n+    AccountInfo,\n     DebridAccountLookupPort,\n     ProviderCooldown,\n     ProviderStreamResolver,\n     StreamResolveCache,\n )\n from app.modules.streaming.domain.entities import StreamResolveRequest\n-from app.modules.streaming.domain.value_objects import StreamResolveResult\n+from app.modules.streaming.domain.value_objects import StreamQuality, StreamResolveResult\n \n-# Overall timeout for a single provider resolve call (multi-step flows like\n-# addMagnet -> selectFiles -> info -> unrestrict can take time).\n _RESOLVE_TIMEOUT_SECONDS = 30\n-\n-# Default cooldown duration when provider returns 5xx without Retry-After.\n _DEFAULT_ERROR_COOLDOWN = 30\n \n+_QUALITY_PRIORITY: dict[StreamQuality, int] = {\n+    StreamQuality.UHD: 6,\n+    StreamQuality.QHD: 5,\n+    StreamQuality.FHD: 4,\n+    StreamQuality.HD: 3,\n+    StreamQuality.SD: 2,\n+    StreamQuality.UNKNOWN: 1,\n+}\n \n+\n class StreamResolveUseCase:\n     \"\"\"Application-layer use case for resolving stream sources.\n \n     Optionally caches successful results behind a ``StreamResolveCache`` port.\n     Optionally tracks provider cooldown behind a ``ProviderCooldown`` port.\n     Cache/cooldown failures are fail-open: they never prevent resolution.\n     \"\"\"\n \n     def __init__(\n         self,\n         account_lookup: DebridAccountLookupPort,\n         resolvers: Mapping[str, ProviderStreamResolver],\n         cache: StreamResolveCache | None = None,\n         provider_cooldown: ProviderCooldown | None = None,\n     ) -> None:\n         self.account_lookup = account_lookup\n         self.resolvers = resolvers\n         self._cache = cache\n         self._provider_cooldown = provider_cooldown\n \n     async def resolve(self, user_id: int, request: StreamResolveRequest) -> list[StreamResolveResult]:\n         \"\"\"Resolve a stream source and return available stream URLs.\n \n         Account selection priority:\n         1. If request specifies account_id, verify ownership and use it.\n         2. If request specifies provider, find the user's account for that provider.\n         3. If neither is specified, pick the first available account.\n \n         When a cache is configured, successful results are cached and\n         subsequent requests for the same key return cached data.\n \n         When provider cooldown is configured, cooldown is checked before\n         calling the resolver and marked on failure.\n         \"\"\"\n \n-        # --- Account selection ---\n         account_id = request.account_id\n         provider_name = request.provider\n \n         if account_id is not None:\n             # Priority 1: explicit account_id -- verify ownership and get credentials\n             resolved_provider = await self.account_lookup.get_account_provider(account_id, user_id)\n             if resolved_provider is None:\n                 raise NoDebridAccountError()\n             api_key = await self.account_lookup.get_api_key(account_id)\n             if api_key is None:\n                 raise NoDebridAccountError()\n             provider_name = resolved_provider\n+            return await self._resolve_with_account(\n+                account_id=account_id,\n+                provider_name=provider_name,\n+                api_key=api_key,\n+                user_id=user_id,\n+                request=request,\n+            )\n \n         else:\n-            # Priority 2 or 3: look up user's accounts\n             accounts = await self.account_lookup.list_by_user(user_id)\n \n             if provider_name is not None:\n                 # Priority 2: find account matching the requested provider\n                 matching = [a for a in accounts if a.provider == provider_name]\n                 if not matching:\n                     raise NoDebridAccountError()\n                 account_id = matching[0].account_id\n             else:\n-                # Priority 3: pick the first account with a registered resolver.\n-                # Account repository ordering is deterministic, but an account for\n-                # an unsupported provider should not block a later usable account.\n+                # Priority 3: pick the first account with a registered resolver\n+                # that supports the requested source type.\n                 matching = [\n                     a\n                     for a in accounts\n                     if (resolver := self.resolvers.get(a.provider)) is not None\n                     and request.source.source_type in resolver.supported_source_types\n                 ]\n                 if not matching:\n                     raise NoDebridAccountError()\n                 account_id = matching[0].account_id\n                 provider_name = matching[0].provider\n \n-            # Fetch API key for the selected account\n             api_key = await self.account_lookup.get_api_key(account_id)\n             if api_key is None:\n                 raise NoDebridAccountError()\n \n+            return await self._resolve_with_account(\n+                account_id=account_id,\n+                provider_name=provider_name,\n+                api_key=api_key,\n+                user_id=user_id,\n+                request=request,\n+            )\n+\n+    async def _resolve_with_account(\n+        self,\n+        account_id: int,\n+        provider_name: str,\n+        api_key: str,\n+        user_id: int,\n+        request: StreamResolveRequest,\n+    ) -> list[StreamResolveResult]:\n+        \"\"\"Resolve a stream source through a specific account.\"\"\"\n         # --- Provider cooldown check ---\n         if self._provider_cooldown is not None:\n             cooling = await self._try_check_cooldown(user_id, provider_name)\n             if cooling:\n                 raise ProviderStreamUnavailableError()\n \n         # --- Cache check ---\n         if self._cache is not None:\n             cache_key = compute_cache_key(\n                 user_id=user_id,\n                 account_id=account_id,\n                 provider=provider_name,\n                 source=request.source,\n             )\n             cached = await self._try_cache_get(cache_key)\n             if cached is not None:\n                 return cached\n \n         # --- Resolve ---\n         resolver = self.resolvers.get(provider_name)\n         if resolver is None:\n             raise UnsupportedStreamSourceError()\n \n         try:\n             results = await asyncio.wait_for(\n                 resolver.resolve(request.source, api_key),\n                 timeout=_RESOLVE_TIMEOUT_SECONDS,\n             )\n         except TimeoutError:\n             raise ProviderStreamUnavailableError() from None\n         except StreamResolveError as exc:\n-            # Mark cooldown on provider failures\n             if self._provider_cooldown is not None:\n                 cooldown = getattr(exc, \"cooldown_seconds\", _DEFAULT_ERROR_COOLDOWN) or _DEFAULT_ERROR_COOLDOWN\n                 await self._try_mark_cooldown(user_id, provider_name, cooldown)\n             raise\n         except Exception:\n             if self._provider_cooldown is not None:\n                 await self._try_mark_cooldown(user_id, provider_name, _DEFAULT_ERROR_COOLDOWN)\n             raise ProviderStreamUnavailableError() from None\n \n-        # Set the resolved account_id on results using dataclasses.replace\n         results = [replace(r, account_id=account_id) for r in results]\n \n         # --- Cache store ---\n         if self._cache is not None and results:\n             await self._try_cache_set(cache_key, results, provider_name)\n \n         return results\n \n     async def _try_check_cooldown(self, user_id: int, provider: str) -> bool:\n         \"\"\"Check cooldown, returning True if provider is cooling down (fail-open).\"\"\"\n         if self._provider_cooldown is None:\n             return False\n         try:\n             return await self._provider_cooldown.is_cooling_down(user_id, provider)\n         except Exception:\n             return False\n \n     async def _try_mark_cooldown(self, user_id: int, provider: str, seconds: int) -> None:\n         \"\"\"Mark cooldown, silently ignoring failures (fail-open).\"\"\"\n         if self._provider_cooldown is None:\n             return\n         with suppress(Exception):\n             await self._provider_cooldown.mark_cooldown(user_id, provider, seconds)\n \n     async def _try_cache_get(self, key: str) -> list[StreamResolveResult] | None:\n         \"\"\"Attempt a cache get, returning None on any failure (fail-open).\"\"\"\n         if self._cache is None:\n             return None\n         try:\n             return await self._cache.get(key)\n         except Exception:\n             return None\n \n     async def _try_cache_set(\n         self,\n         key: str,\n         results: list[StreamResolveResult],\n         provider_name: str,\n     ) -> None:\n         \"\"\"Attempt a cache set, silently ignoring failures (fail-open).\"\"\"\n         if self._cache is None:\n             return\n         try:\n             ttl = default_cache_ttl_seconds()\n-            # Respect per-result expires_at if present\n             ttl = bounded_cache_ttl_seconds(ttl)\n             await self._cache.set(key, results, ttl)\n         except Exception:\n             pass\n",
    "additions": 39,
    "deletions": 14,
    "status": "modified"
  },
  {
    "file": "app/modules/streaming/infrastructure/_shared.py",
    "patch": "Index: app/modules/streaming/infrastructure/_shared.py\n===================================================================\n--- app/modules/streaming/infrastructure/_shared.py\t\n+++ app/modules/streaming/infrastructure/_shared.py\t\n@@ -0,0 +1,293 @@\n+\"\"\"Shared helpers for debrid provider resolvers.\n+\n+Extracted from provider_resolvers.py to DRY up common HTTP request\n+patterns, validation, and inference logic used by all resolvers.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import math\n+import re\n+from collections.abc import Mapping\n+from datetime import datetime\n+from pathlib import PurePosixPath\n+from typing import Any\n+from urllib.parse import urlparse\n+\n+import httpx\n+\n+from app.modules.streaming.application.exceptions import ProviderStreamUnavailableError\n+from app.modules.streaming.domain.value_objects import StreamQuality\n+\n+# ── HTTP configuration ───────────────────────────────────────────────────\n+\n+_DEFAULT_TIMEOUT = httpx.Timeout(connect=3.0, read=10.0, write=5.0, pool=2.0)\n+_DEFAULT_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)\n+_RETRYABLE_STATUS_CODES = {502, 503, 504}\n+_SAFE_RETRY_METHODS = {\"GET\", \"HEAD\", \"OPTIONS\"}\n+_MAX_PROVIDER_FILES = 50\n+_MAX_FILENAME_LENGTH = 255\n+_MAX_FILE_SIZE_BYTES = 10 * 1024**5  # 10 PiB, guards absurd provider payloads without rejecting real media.\n+_CONTROL_CHARS_PATTERN = re.compile(r\"[\\x00-\\x1f\\x7f]\")\n+_INFO_HASH_PATTERN = re.compile(r\"^(?:[0-9a-fA-F]{40}|[A-Z2-7]{32})$\")\n+\n+# ── MIME type inference ──────────────────────────────────────────────────\n+\n+_MIME_MAP: dict[str, str] = {\n+    \".mp4\": \"video/mp4\",\n+    \".mkv\": \"video/x-matroska\",\n+    \".avi\": \"video/x-msvideo\",\n+    \".mov\": \"video/quicktime\",\n+    \".wmv\": \"video/x-ms-wmv\",\n+    \".flv\": \"video/x-flv\",\n+    \".webm\": \"video/webm\",\n+    \".m4v\": \"video/x-m4v\",\n+    \".mp3\": \"audio/mpeg\",\n+    \".aac\": \"audio/aac\",\n+    \".flac\": \"audio/flac\",\n+    \".ogg\": \"audio/ogg\",\n+    \".opus\": \"audio/opus\",\n+    \".wma\": \"audio/x-ms-wma\",\n+    \".m3u8\": \"application/x-mpegURL\",\n+    \".ts\": \"video/mp2t\",\n+    \".m2ts\": \"video/mp2t\",\n+    \".iso\": \"application/x-iso9660-image\",\n+}\n+_PLAYABLE_MEDIA_MIME_PREFIXES = (\"video/\", \"audio/\")\n+_PLAYABLE_MEDIA_MIME_TYPES = {\"application/x-mpegurl\", \"application/x-iso9660-image\"}\n+\n+# ── Quality inference patterns ───────────────────────────────────────────\n+\n+_QUALITY_PATTERNS: list[tuple[str, StreamQuality]] = [\n+    (\"4k\", StreamQuality.UHD),\n+    (\"2160p\", StreamQuality.UHD),\n+    (\"1080p\", StreamQuality.FHD),\n+    (\"720p\", StreamQuality.HD),\n+    (\"480p\", StreamQuality.SD),\n+    (\"360p\", StreamQuality.SD),\n+    (\"240p\", StreamQuality.SD),\n+    (\"audio\", StreamQuality.UNKNOWN),\n+]\n+\n+\n+def infer_quality(filename: str) -> StreamQuality:\n+    \"\"\"Infer video quality from a filename.\"\"\"\n+    lower = filename.lower()\n+    for pattern, quality in _QUALITY_PATTERNS:\n+        if pattern in lower:\n+            return quality\n+    return StreamQuality.UNKNOWN\n+\n+\n+def infer_mime_type(filename: str) -> str | None:\n+    \"\"\"Infer MIME type from a file extension.\"\"\"\n+    if not filename or \".\" not in filename:\n+        return None\n+    _, ext = filename.rsplit(\".\", 1)\n+    ext = f\".{ext.lower()}\"\n+    return _MIME_MAP.get(ext)\n+\n+\n+def is_playable_media(filename: str) -> bool:\n+    \"\"\"Return True when a provider file looks like playable audio/video media.\"\"\"\n+    mime_type = infer_mime_type(filename)\n+    if mime_type is None:\n+        return False\n+    normalized = mime_type.lower()\n+    return normalized.startswith(_PLAYABLE_MEDIA_MIME_PREFIXES) or normalized in _PLAYABLE_MEDIA_MIME_TYPES\n+\n+\n+def parse_datetime(value: Any) -> datetime | None:\n+    \"\"\"Parse provider date strings defensively.\"\"\"\n+    if value in (None, \"\"):\n+        return None\n+    if not isinstance(value, str):\n+        return None\n+    try:\n+        return datetime.fromisoformat(value.replace(\"Z\", \"+00:00\"))\n+    except (ValueError, TypeError):\n+        return None\n+\n+\n+def _number(value: Any, default: float = 0) -> float:\n+    \"\"\"Coerce provider numeric fields that may arrive as int/float/string.\"\"\"\n+    if value in (None, \"\"):\n+        return default\n+    if isinstance(value, bool):\n+        return default\n+    try:\n+        number = float(value)\n+    except (TypeError, ValueError):\n+        return default\n+    if not math.isfinite(number):\n+        raise ProviderStreamUnavailableError()\n+    return number\n+\n+\n+def size_bytes(value: Any) -> int:\n+    \"\"\"Coerce provider file sizes into safe non-negative integer bytes.\"\"\"\n+    size = int(_number(value, 0))\n+    if size < 0 or size > _MAX_FILE_SIZE_BYTES:\n+        raise ProviderStreamUnavailableError()\n+    return size\n+\n+\n+def safe_filename(value: Any, default: str = \"unknown\") -> str:\n+    \"\"\"Normalize provider-controlled filenames for API/UI use.\n+\n+    Providers can return paths, control characters, or very long names. Keep the\n+    final path segment, strip control characters, and bound length so responses\n+    remain predictable without leaking path-like metadata into clients/logs.\n+    \"\"\"\n+    if not isinstance(value, str):\n+        return default\n+    cleaned = _CONTROL_CHARS_PATTERN.sub(\"\", value).strip()\n+    if not cleaned:\n+        return default\n+    filename = PurePosixPath(cleaned.replace(\"\\\\\", \"/\")).name or default\n+    if filename in {\".\", \"..\"}:\n+        return default\n+    return filename[:_MAX_FILENAME_LENGTH] or default\n+\n+\n+def _is_safe_stream_url(value: str) -> bool:\n+    \"\"\"Return True when a provider stream URL is an absolute HTTPS URL.\n+\n+    Stream URLs are handed back to clients as playable links, so require TLS and\n+    reject embedded credentials. Input source URLs may still be HTTP if a\n+    provider supports fetching them, but returned playback URLs must be HTTPS.\n+    \"\"\"\n+    parsed = urlparse(value)\n+    if parsed.scheme != \"https\":\n+        return False\n+    return bool(parsed.netloc and parsed.hostname and not parsed.username and not parsed.password)\n+\n+\n+def stream_url(value: Any) -> str:\n+    \"\"\"Extract and validate a provider-returned playable stream URL.\"\"\"\n+    if not isinstance(value, str) or not value.strip():\n+        raise ProviderStreamUnavailableError()\n+    stream_url_val = value.strip()\n+    if not _is_safe_stream_url(stream_url_val):\n+        raise ProviderStreamUnavailableError()\n+    return stream_url_val\n+\n+\n+def _dict(value: Any) -> Mapping[str, Any]:\n+    \"\"\"Defensively coerce a value to a dict mapping.\"\"\"\n+    if not isinstance(value, dict):\n+        raise ValueError(\"Expected dict\")\n+    return value\n+\n+\n+def parse_retry_after(header_value: str | None) -> int | None:\n+    \"\"\"Parse Retry-After header, returning seconds or None if absent.\n+\n+    Supports both delta-seconds (integer) and HTTP-date formats.\n+    \"\"\"\n+    if not header_value:\n+        return None\n+    try:\n+        seconds = int(header_value)\n+        return max(1, min(seconds, 300))  # Cap at 5 minutes\n+    except ValueError:\n+        return None\n+\n+\n+async def request_json(\n+    client: httpx.AsyncClient,\n+    method: str,\n+    url: str,\n+    *,\n+    retry_once: bool = True,\n+    **kwargs: Any,\n+) -> Mapping[str, Any]:\n+    \"\"\"Send a provider request and return JSON with sanitized errors.\n+\n+    Raw httpx exceptions can include request URLs and auth query strings,\n+    so they are translated here before reaching logs or API responses.\n+    \"\"\"\n+    method_upper = method.upper()\n+    can_retry = retry_once and method_upper in _SAFE_RETRY_METHODS\n+    attempts = 2 if can_retry else 1\n+    response: httpx.Response | None = None\n+    for attempt in range(attempts):\n+        try:\n+            response = await client.request(method, url, **kwargs)\n+        except (httpx.TimeoutException, httpx.RequestError):\n+            if attempt + 1 < attempts:\n+                await asyncio.sleep(0)\n+                continue\n+            raise ProviderStreamUnavailableError() from None\n+\n+        if response.status_code in _RETRYABLE_STATUS_CODES and attempt + 1 < attempts:\n+            await asyncio.sleep(0)\n+            continue\n+        break\n+\n+    if response is None:\n+        raise ProviderStreamUnavailableError()\n+    if response.status_code in (401, 403):\n+        raise ProviderStreamUnavailableError()\n+    if response.status_code == 429:\n+        retry_after_val = parse_retry_after(response.headers.get(\"Retry-After\"))\n+        raise ProviderStreamUnavailableError(cooldown_seconds=retry_after_val)\n+    if response.status_code >= 500:\n+        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n+    if response.status_code >= 400:\n+        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n+\n+    try:\n+        data = response.json()\n+    except ValueError as exc:\n+        raise ProviderStreamUnavailableError() from exc\n+    if not isinstance(data, dict):\n+        raise ProviderStreamUnavailableError()\n+    return data\n+\n+\n+async def request_no_content(\n+    client: httpx.AsyncClient,\n+    method: str,\n+    url: str,\n+    *,\n+    retry_once: bool = True,\n+    **kwargs: Any,\n+) -> None:\n+    \"\"\"Send a provider request expecting a no-content (2xx) response.\n+\n+    Like request_json but for endpoints that return no body (e.g. 204).\n+    Raw httpx exceptions are sanitized to avoid credential leakage.\n+    \"\"\"\n+    method_upper = method.upper()\n+    can_retry = retry_once and method_upper in _SAFE_RETRY_METHODS\n+    attempts = 2 if can_retry else 1\n+    response: httpx.Response | None = None\n+    for attempt in range(attempts):\n+        try:\n+            response = await client.request(method, url, **kwargs)\n+        except (httpx.TimeoutException, httpx.RequestError):\n+            if attempt + 1 < attempts:\n+                await asyncio.sleep(0)\n+                continue\n+            raise ProviderStreamUnavailableError() from None\n+\n+        if response.status_code in _RETRYABLE_STATUS_CODES and attempt + 1 < attempts:\n+            await asyncio.sleep(0)\n+            continue\n+        break\n+\n+    if response is None:\n+        raise ProviderStreamUnavailableError()\n+    if response.status_code in (401, 403):\n+        raise ProviderStreamUnavailableError()\n+    if response.status_code == 429:\n+        retry_after_val = parse_retry_after(response.headers.get(\"Retry-After\"))\n+        raise ProviderStreamUnavailableError(cooldown_seconds=retry_after_val)\n+    if response.status_code >= 500:\n+        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n+    if response.status_code >= 400:\n+        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n+    if response.status_code < 200 or response.status_code >= 300:\n+        raise ProviderStreamUnavailableError()\n",
    "additions": 293,
    "deletions": 0,
    "status": "added"
  },
  {
    "file": "app/modules/streaming/infrastructure/provider_resolvers.py",
    "patch": "Index: app/modules/streaming/infrastructure/provider_resolvers.py\n===================================================================\n--- app/modules/streaming/infrastructure/provider_resolvers.py\t\n+++ app/modules/streaming/infrastructure/provider_resolvers.py\t\n@@ -1,990 +1,747 @@\n-\"\"\"Real-Debrid stream resolver implementation.\n+\"\"\"Debrid provider stream resolver implementations.\n \n-Implements the ProviderStreamResolver port for resolving streams through\n-the Real-Debrid API. Infrastructure layer — the application layer never\n+Implements the ProviderStreamResolver port for Real-Debrid, AllDebrid,\n+TorBox, and Premiumize. Infrastructure layer — the application layer never\n imports this module directly.\n \"\"\"\n \n-import asyncio\n-import math\n-import re\n-from collections.abc import Mapping\n-from datetime import datetime\n-from pathlib import PurePosixPath\n-from typing import Any\n+from __future__ import annotations\n+\n from urllib.parse import parse_qs, urlparse\n \n import httpx\n \n from app.modules.streaming.application.exceptions import (\n     ProviderStreamUnavailableError,\n     UnsupportedStreamSourceError,\n )\n from app.modules.streaming.application.interfaces import ProviderStreamResolver\n from app.modules.streaming.domain.value_objects import (\n     ResolveSource,\n     ResolveSourceType,\n-    StreamQuality,\n     StreamResolveResult,\n )\n+from app.modules.streaming.infrastructure._shared import (\n+    _DEFAULT_LIMITS,\n+    _DEFAULT_TIMEOUT,\n+    _INFO_HASH_PATTERN,\n+    _MAX_PROVIDER_FILES,\n+    infer_mime_type,\n+    infer_quality,\n+    is_playable_media,\n+    parse_datetime,\n+    request_json,\n+    request_no_content,\n+    safe_filename,\n+    size_bytes,\n+    stream_url,\n+)\n \n-# ── HTTP configuration (mirrors debrid module) ─────────────────────────\n-\n-_DEFAULT_TIMEOUT = httpx.Timeout(connect=3.0, read=10.0, write=5.0, pool=2.0)\n-_DEFAULT_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)\n-_RETRYABLE_STATUS_CODES = {502, 503, 504}\n-_SAFE_RETRY_METHODS = {\"GET\", \"HEAD\", \"OPTIONS\"}\n-_MAX_PROVIDER_FILES = 50\n-_MAX_FILENAME_LENGTH = 255\n-_MAX_FILE_SIZE_BYTES = 10 * 1024**5  # 10 PiB, guards absurd provider payloads without rejecting real media.\n-_CONTROL_CHARS_PATTERN = re.compile(r\"[\\x00-\\x1f\\x7f]\")\n-_INFO_HASH_PATTERN = re.compile(r\"^(?:[0-9a-fA-F]{40}|[A-Z2-7]{32})$\")\n-\n-# ── MIME type inference ────────────────────────────────────────────────\n-\n-_MIME_MAP: dict[str, str] = {\n-    \".mp4\": \"video/mp4\",\n-    \".mkv\": \"video/x-matroska\",\n-    \".avi\": \"video/x-msvideo\",\n-    \".mov\": \"video/quicktime\",\n-    \".wmv\": \"video/x-ms-wmv\",\n-    \".flv\": \"video/x-flv\",\n-    \".webm\": \"video/webm\",\n-    \".m4v\": \"video/x-m4v\",\n-    \".mp3\": \"audio/mpeg\",\n-    \".aac\": \"audio/aac\",\n-    \".flac\": \"audio/flac\",\n-    \".ogg\": \"audio/ogg\",\n-    \".opus\": \"audio/opus\",\n-    \".wma\": \"audio/x-ms-wma\",\n-    \".m3u8\": \"application/x-mpegURL\",\n-    \".ts\": \"video/mp2t\",\n-    \".m2ts\": \"video/mp2t\",\n-    \".iso\": \"application/x-iso9660-image\",\n-}\n-_PLAYABLE_MEDIA_MIME_PREFIXES = (\"video/\", \"audio/\")\n-_PLAYABLE_MEDIA_MIME_TYPES = {\"application/x-mpegurl\", \"application/x-iso9660-image\"}\n-\n-# ── Quality inference patterns ─────────────────────────────────────────\n-\n-_QUALITY_PATTERNS: list[tuple[str, StreamQuality]] = [\n-    (\"4k\", StreamQuality.UHD),\n-    (\"2160p\", StreamQuality.UHD),\n-    (\"1080p\", StreamQuality.FHD),\n-    (\"720p\", StreamQuality.HD),\n-    (\"480p\", StreamQuality.SD),\n-    (\"360p\", StreamQuality.SD),\n-    (\"240p\", StreamQuality.SD),\n-    (\"audio\", StreamQuality.UNKNOWN),\n+__all__ = [\n+    \"AllDebridResolver\",\n+    \"PremiumizeResolver\",\n+    \"RealDebridResolver\",\n+    \"TorBoxResolver\",\n ]\n \n+# Backward-compatible re-exports for code importing from this module\n+_infer_mime_type = infer_mime_type\n+_infer_quality = infer_quality\n+_request_json = request_json\n+_request_no_content = request_no_content\n+_safe_filename = safe_filename\n+_stream_url = stream_url\n+_size_bytes = size_bytes\n+_parse_datetime = parse_datetime\n+_is_playable_media = is_playable_media\n \n-def _infer_quality(filename: str) -> StreamQuality:\n-    \"\"\"Infer video quality from a filename.\"\"\"\n-    lower = filename.lower()\n-    for pattern, quality in _QUALITY_PATTERNS:\n-        if pattern in lower:\n-            return quality\n-    return StreamQuality.UNKNOWN\n \n+class _BaseResolverMixin:\n+    \"\"\"Shared resolver helpers that don't need per-resolver state.\"\"\"\n \n-def _infer_mime_type(filename: str) -> str | None:\n-    \"\"\"Infer MIME type from a file extension.\"\"\"\n-    if not filename or \".\" not in filename:\n-        return None\n-    _, ext = filename.rsplit(\".\", 1)\n-    ext = f\".{ext.lower()}\"\n-    return _MIME_MAP.get(ext)\n-\n-\n-def _is_playable_media(filename: str) -> bool:\n-    \"\"\"Return True when a provider file looks like playable audio/video media.\"\"\"\n-    mime_type = _infer_mime_type(filename)\n-    if mime_type is None:\n-        return False\n-    normalized = mime_type.lower()\n-    return normalized.startswith(_PLAYABLE_MEDIA_MIME_PREFIXES) or normalized in _PLAYABLE_MEDIA_MIME_TYPES\n-\n-\n-def _parse_datetime(value: Any) -> datetime | None:\n-    \"\"\"Parse provider date strings defensively.\"\"\"\n-    if value in (None, \"\"):\n-        return None\n-    if not isinstance(value, str):\n-        return None\n-    try:\n-        return datetime.fromisoformat(value.replace(\"Z\", \"+00:00\"))\n-    except (ValueError, TypeError):\n-        return None\n-\n-\n-def _number(value: Any, default: float = 0) -> float:\n-    \"\"\"Coerce provider numeric fields that may arrive as int/float/string.\"\"\"\n-    if value in (None, \"\"):\n-        return default\n-    if isinstance(value, bool):\n-        return default\n-    try:\n-        number = float(value)\n-    except (TypeError, ValueError):\n-        return default\n-    if not math.isfinite(number):\n-        raise ProviderStreamUnavailableError()\n-    return number\n-\n-\n-def _size_bytes(value: Any) -> int:\n-    \"\"\"Coerce provider file sizes into safe non-negative integer bytes.\"\"\"\n-    size = int(_number(value, 0))\n-    if size < 0 or size > _MAX_FILE_SIZE_BYTES:\n-        raise ProviderStreamUnavailableError()\n-    return size\n-\n-\n-def _safe_filename(value: Any, default: str = \"unknown\") -> str:\n-    \"\"\"Normalize provider-controlled filenames for API/UI use.\n-\n-    Providers can return paths, control characters, or very long names. Keep the\n-    final path segment, strip control characters, and bound length so responses\n-    remain predictable without leaking path-like metadata into clients/logs.\n-    \"\"\"\n-    if not isinstance(value, str):\n-        return default\n-    cleaned = _CONTROL_CHARS_PATTERN.sub(\"\", value).strip()\n-    if not cleaned:\n-        return default\n-    filename = PurePosixPath(cleaned.replace(\"\\\\\", \"/\")).name or default\n-    if filename in {\".\", \"..\"}:\n-        return default\n-    return filename[:_MAX_FILENAME_LENGTH] or default\n-\n-\n-def _is_safe_stream_url(value: str) -> bool:\n-    \"\"\"Return True when a provider stream URL is an absolute HTTPS URL.\n-\n-    Stream URLs are handed back to clients as playable links, so require TLS and\n-    reject embedded credentials. Input source URLs may still be HTTP if a\n-    provider supports fetching them, but returned playback URLs must be HTTPS.\n-    \"\"\"\n-    parsed = urlparse(value)\n-    if parsed.scheme != \"https\":\n-        return False\n-    return bool(parsed.netloc and parsed.hostname and not parsed.username and not parsed.password)\n-\n-\n-def _stream_url(value: Any) -> str:\n-    \"\"\"Extract and validate a provider-returned playable stream URL.\"\"\"\n-    if not isinstance(value, str) or not value.strip():\n-        raise ProviderStreamUnavailableError()\n-    stream_url = value.strip()\n-    if not _is_safe_stream_url(stream_url):\n-        raise ProviderStreamUnavailableError()\n-    return stream_url\n-\n-\n-def _dict(value: Any) -> Mapping[str, Any]:\n-    \"\"\"Defensively coerce a value to a dict mapping.\"\"\"\n-    if not isinstance(value, dict):\n-        raise ValueError(\"Expected dict\")\n-    return value\n-\n-\n-def _parse_retry_after(header_value: str | None) -> int | None:\n-    \"\"\"Parse Retry-After header, returning seconds or None if absent.\n-\n-    Supports both delta-seconds (integer) and HTTP-date formats.\n-    \"\"\"\n-    if not header_value:\n-        return None\n-    try:\n-        seconds = int(header_value)\n-        return max(1, min(seconds, 300))  # Cap at 5 minutes\n-    except ValueError:\n-        return None\n-\n-\n-async def _request_json(\n-    client: httpx.AsyncClient,\n-    method: str,\n-    url: str,\n-    *,\n-    retry_once: bool = True,\n-    **kwargs: Any,\n-) -> Mapping[str, Any]:\n-    \"\"\"Send a provider request and return JSON with sanitized errors.\n-\n-    Raw httpx exceptions can include request URLs and auth query strings,\n-    so they are translated here before reaching logs or API responses.\n-    \"\"\"\n-    method_upper = method.upper()\n-    can_retry = retry_once and method_upper in _SAFE_RETRY_METHODS\n-    attempts = 2 if can_retry else 1\n-    response: httpx.Response | None = None\n-    for attempt in range(attempts):\n+    @staticmethod\n+    def _extract_hash(magnet: str) -> str | None:\n+        \"\"\"Extract and validate the btih info-hash from a magnet link.\"\"\"\n         try:\n-            response = await client.request(method, url, **kwargs)\n-        except (httpx.TimeoutException, httpx.RequestError):\n-            if attempt + 1 < attempts:\n-                await asyncio.sleep(0)\n-                continue\n-            # Do not chain raw httpx exceptions: they may include request URLs\n-            # with query-param credentials in tracebacks/log aggregation.\n-            raise ProviderStreamUnavailableError() from None\n+            parsed = urlparse(magnet)\n+            if parsed.scheme != \"magnet\":\n+                return None\n+            xt_values = parse_qs(parsed.query, keep_blank_values=False).get(\"xt\", [])\n+            for xt_value in xt_values:\n+                prefix = \"urn:btih:\"\n+                if not xt_value.startswith(prefix):\n+                    continue\n+                hash_value = xt_value[len(prefix):]\n+                if _INFO_HASH_PATTERN.fullmatch(hash_value):\n+                    return hash_value\n+            return None\n+        except (ValueError, IndexError):\n+            return None\n \n-        if response.status_code in _RETRYABLE_STATUS_CODES and attempt + 1 < attempts:\n-            await asyncio.sleep(0)\n-            continue\n-        break\n \n-    if response is None:\n-        raise ProviderStreamUnavailableError()\n-    if response.status_code in (401, 403):\n-        raise ProviderStreamUnavailableError()\n-    if response.status_code == 429:\n-        # Carry Retry-After for circuit-breaker cooldown\n-        retry_after = _parse_retry_after(response.headers.get(\"Retry-After\"))\n-        raise ProviderStreamUnavailableError(cooldown_seconds=retry_after)\n-    if response.status_code >= 500:\n-        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n-    if response.status_code >= 400:\n-        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n-\n-    try:\n-        data = response.json()\n-    except ValueError as exc:\n-        raise ProviderStreamUnavailableError() from exc\n-    if not isinstance(data, dict):\n-        raise ProviderStreamUnavailableError()\n-    return data\n-\n-\n-async def _request_no_content(\n-    client: httpx.AsyncClient,\n-    method: str,\n-    url: str,\n-    *,\n-    retry_once: bool = True,\n-    **kwargs: Any,\n-) -> None:\n-    \"\"\"Send a provider request expecting a no-content (2xx) response.\n-\n-    Like _request_json but for endpoints that return no body (e.g. 204).\n-    Raw httpx exceptions are sanitized to avoid credential leakage.\n-    \"\"\"\n-    method_upper = method.upper()\n-    can_retry = retry_once and method_upper in _SAFE_RETRY_METHODS\n-    attempts = 2 if can_retry else 1\n-    response: httpx.Response | None = None\n-    for attempt in range(attempts):\n-        try:\n-            response = await client.request(method, url, **kwargs)\n-        except (httpx.TimeoutException, httpx.RequestError):\n-            if attempt + 1 < attempts:\n-                await asyncio.sleep(0)\n-                continue\n-            raise ProviderStreamUnavailableError() from None\n-\n-        if response.status_code in _RETRYABLE_STATUS_CODES and attempt + 1 < attempts:\n-            await asyncio.sleep(0)\n-            continue\n-        break\n-\n-    if response is None:\n-        raise ProviderStreamUnavailableError()\n-    if response.status_code in (401, 403):\n-        raise ProviderStreamUnavailableError()\n-    if response.status_code == 429:\n-        # Carry Retry-After for circuit-breaker cooldown\n-        retry_after = _parse_retry_after(response.headers.get(\"Retry-After\"))\n-        raise ProviderStreamUnavailableError(cooldown_seconds=retry_after)\n-    if response.status_code >= 500:\n-        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n-    if response.status_code >= 400:\n-        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n-    # Any 2xx (including 204 No Content) is success\n-    if response.status_code < 200 or response.status_code >= 300:\n-        raise ProviderStreamUnavailableError()\n-\n-\n-class RealDebridResolver(ProviderStreamResolver):\n+class RealDebridResolver(ProviderStreamResolver, _BaseResolverMixin):\n     \"\"\"Real-Debrid stream resolver.\n \n     Implements the ProviderStreamResolver port. Resolves magnet links,\n     torrent URLs, direct URLs, and info-hashes through the Real-Debrid API.\n     \"\"\"\n \n     provider: str = \"real_debrid\"\n     BASE_URL: str = \"https://api.real-debrid.com/rest/1.0\"\n     supported_source_types = frozenset(\n         {ResolveSourceType.DIRECT_URL, ResolveSourceType.MAGNET, ResolveSourceType.INFO_HASH}\n     )\n \n     def __init__(self, http_client: httpx.AsyncClient | None = None) -> None:\n         self._owns_client = http_client is None\n         self._client = http_client or httpx.AsyncClient(\n             timeout=_DEFAULT_TIMEOUT,\n             limits=_DEFAULT_LIMITS,\n         )\n \n     async def close(self) -> None:\n         \"\"\"Close the underlying HTTP client if this wrapper created it.\"\"\"\n         if self._owns_client:\n             await self._client.aclose()\n \n     async def resolve(self, source: ResolveSource, api_key: str) -> list[StreamResolveResult]:\n-        \"\"\"Resolve a stream source and return available stream URLs.\n-\n-        Args:\n-            source: The resolved source to look up.\n-            api_key: Real-Debrid API key.\n-\n-        Returns:\n-            A list of resolved stream results.\n-\n-        Raises:\n-            UnsupportedStreamSourceError: If the source type is not supported.\n-            ProviderStreamUnavailableError: If the provider fails to resolve.\n-        \"\"\"\n+        \"\"\"Resolve a stream source and return available stream URLs.\"\"\"\n         headers = {\"Authorization\": f\"Bearer {api_key}\"}\n \n         if source.source_type == ResolveSourceType.DIRECT_URL:\n             return await self._resolve_direct(source, headers)\n \n         if source.source_type in (\n             ResolveSourceType.MAGNET,\n             ResolveSourceType.INFO_HASH,\n         ):\n             return await self._resolve_torrent(source, headers)\n \n         raise UnsupportedStreamSourceError()\n \n     async def _resolve_direct(self, source: ResolveSource, headers: dict[str, str]) -> list[StreamResolveResult]:\n         \"\"\"Resolve a direct URL through /unrestrict/link.\"\"\"\n-        data = await _request_json(\n+        data = await request_json(\n             self._client,\n             \"POST\",\n             f\"{self.BASE_URL}/unrestrict/link\",\n             headers=headers,\n             data={\"link\": source.value},\n         )\n \n-        filename = _safe_filename(data.get(\"filename\"))\n-        if not _is_playable_media(filename):\n+        filename = safe_filename(data.get(\"filename\"))\n+        if not is_playable_media(filename):\n             return []\n \n-        size = _size_bytes(data.get(\"filesize\"))\n-        stream_url = _stream_url(data.get(\"link\"))\n+        file_size = size_bytes(data.get(\"filesize\"))\n+        stream_url_val = stream_url(data.get(\"link\"))\n \n         return [\n             StreamResolveResult(\n                 provider=self.provider,\n                 account_id=0,\n                 source_type=source.source_type,\n                 title=filename,\n-                stream_url=stream_url,\n+                stream_url=stream_url_val,\n                 filename=filename,\n-                size_bytes=size,\n-                mime_type=_infer_mime_type(filename),\n-                quality=_infer_quality(filename),\n+                size_bytes=file_size,\n+                mime_type=infer_mime_type(filename),\n+                quality=infer_quality(filename),\n                 expires_at=None,\n                 cached=False,\n             )\n         ]\n \n+    async def _check_instant_availability(self, info_hash: str, headers: dict[str, str]) -> bool:\n+        \"\"\"Check if a torrent is already cached in Real-Debrid's system.\n+\n+        Uses ``GET /torrents/instantAvailability/{hash}`` which returns\n+        file details when the hash is cached, or an empty object when not.\n+        Fail-open: returns False on any error so the caller falls through\n+        to the standard flow.\n+        \"\"\"\n+        try:\n+            data = await request_json(\n+                self._client,\n+                \"GET\",\n+                f\"{self.BASE_URL}/torrents/instantAvailability/{info_hash}\",\n+                headers=headers,\n+                retry_once=False,\n+            )\n+            # Cached response is non-empty dict with the hash as key\n+            return bool(data and isinstance(data, dict) and data.get(info_hash))\n+        except ProviderStreamUnavailableError:\n+            return False\n+\n     async def _resolve_torrent(self, source: ResolveSource, headers: dict[str, str]) -> list[StreamResolveResult]:\n         \"\"\"Resolve a magnet/info-hash through the Real-Debrid torrent API.\n \n-        Correct RD flow:\n-        1. POST /torrents/addMagnet — add the magnet link\n-        2. POST /torrents/selectFiles/{id} — select all files\n-        3. GET /torrents/info/{id} — get file links\n-        4. POST /unrestrict/link — unrestrict each link for playable URL\n+        Optimized RD flow:\n+        1. GET /torrents/instantAvailability/{hash} — pre-flight cached check\n+        2. POST /torrents/addMagnet — add the magnet link\n+        3. POST /torrents/selectFiles/{id} — select all files (skipped if cached)\n+        4. GET /torrents/info/{id} — get file links\n+        5. POST /unrestrict/link — unrestrict each link for playable URL\n         \"\"\"\n         hash_value: str = source.value\n \n-        # For magnet links, extract the info-hash\n         if source.source_type == ResolveSourceType.MAGNET:\n             extracted = self._extract_hash(source.value)\n             if extracted is None:\n                 raise UnsupportedStreamSourceError()\n             hash_value = extracted\n \n-        # Construct magnet URL (from full magnet or info-hash)\n         magnet_url: str\n         if source.source_type == ResolveSourceType.MAGNET:\n             magnet_url = source.value\n         else:\n             magnet_url = f\"magnet:?xt=urn:btih:{hash_value}\"\n \n-        # Step 1: Add magnet link\n-        add_response = await _request_json(\n+        # Pre-flight: check if torrent is already cached in RD's system.\n+        # If cached, we can skip selectFiles, reducing latency from ~3-5s to ~0.5s.\n+        is_cached = await self._check_instant_availability(hash_value, headers)\n+\n+        add_response = await request_json(\n             self._client,\n             \"POST\",\n             f\"{self.BASE_URL}/torrents/addMagnet\",\n             headers=headers,\n             data={\"magnet\": magnet_url},\n         )\n \n         torrent_id = add_response.get(\"id\")\n         if torrent_id is None:\n             raise ProviderStreamUnavailableError()\n \n-        # Step 2: Select all files (required before RD processes the torrent)\n-        await _request_no_content(\n-            self._client,\n-            \"POST\",\n-            f\"{self.BASE_URL}/torrents/selectFiles/{torrent_id}\",\n-            headers=headers,\n-            data={\"files\": \"all\"},\n-        )\n+        # Skip selectFiles for cached torrents — files are already available\n+        if not is_cached:\n+            await request_no_content(\n+                self._client,\n+                \"POST\",\n+                f\"{self.BASE_URL}/torrents/selectFiles/{torrent_id}\",\n+                headers=headers,\n+                data={\"files\": \"all\"},\n+            )\n \n-        # Step 3: Get torrent info with links\n-        info_data = await _request_json(\n+        info_data = await request_json(\n             self._client,\n             \"GET\",\n             f\"{self.BASE_URL}/torrents/info/{torrent_id}\",\n             headers=headers,\n         )\n \n         links = info_data.get(\"links\", [])\n         if not isinstance(links, list) or not links:\n             return []\n \n         results: list[StreamResolveResult] = []\n         for link_url in links[:_MAX_PROVIDER_FILES]:\n             if not isinstance(link_url, str) or not link_url.strip():\n                 continue\n \n-            # Step 4: Unrestrict link to get playable stream URL\n             try:\n-                unrestrict_data = await _request_json(\n+                unrestrict_data = await request_json(\n                     self._client,\n                     \"POST\",\n                     f\"{self.BASE_URL}/unrestrict/link\",\n                     headers=headers,\n                     data={\"link\": link_url},\n                 )\n             except ProviderStreamUnavailableError:\n                 continue\n \n             try:\n-                stream_url = _stream_url(unrestrict_data.get(\"link\"))\n+                stream_url_val = stream_url(unrestrict_data.get(\"link\"))\n             except ProviderStreamUnavailableError:\n                 continue\n \n-            filename = _safe_filename(\n+            filename = safe_filename(\n                 unrestrict_data.get(\"filename\") or info_data.get(\"filename\")\n             )\n-            if not _is_playable_media(filename):\n+            if not is_playable_media(filename):\n                 continue\n \n             try:\n-                size = _size_bytes(unrestrict_data.get(\"filesize\", 0))\n+                file_size = size_bytes(unrestrict_data.get(\"filesize\", 0))\n             except ProviderStreamUnavailableError:\n                 continue\n \n             host_cache = info_data.get(\"status\") == \"downloaded\"\n             if not isinstance(host_cache, bool):\n                 host_cache = False\n \n             added = info_data.get(\"added\")\n-            expires_at = _parse_datetime(added)\n+            expires_at = parse_datetime(added)\n \n             results.append(\n                 StreamResolveResult(\n                     provider=self.provider,\n                     account_id=0,\n                     source_type=source.source_type,\n                     title=filename,\n-                    stream_url=stream_url,\n+                    stream_url=stream_url_val,\n                     filename=filename,\n-                    size_bytes=size,\n-                    mime_type=_infer_mime_type(filename),\n-                    quality=_infer_quality(filename),\n+                    size_bytes=file_size,\n+                    mime_type=infer_mime_type(filename),\n+                    quality=infer_quality(filename),\n                     expires_at=expires_at,\n                     cached=host_cache,\n                 )\n             )\n \n         return results\n \n-    @staticmethod\n-    def _extract_hash(magnet: str) -> str | None:\n-        \"\"\"Extract and validate the btih info-hash from a magnet link.\"\"\"\n-        try:\n-            parsed = urlparse(magnet)\n-            if parsed.scheme != \"magnet\":\n-                return None\n-            xt_values = parse_qs(parsed.query, keep_blank_values=False).get(\"xt\", [])\n-            for xt_value in xt_values:\n-                prefix = \"urn:btih:\"\n-                if not xt_value.startswith(prefix):\n-                    continue\n-                hash_value = xt_value[len(prefix) :]\n-                if _INFO_HASH_PATTERN.fullmatch(hash_value):\n-                    return hash_value\n-            return None\n-        except (ValueError, IndexError):\n-            return None\n \n-\n-class AllDebridResolver(ProviderStreamResolver):\n+class AllDebridResolver(ProviderStreamResolver, _BaseResolverMixin):\n     \"\"\"AllDebrid stream resolver.\n \n     Implements the ProviderStreamResolver port. Resolves magnet links\n     and direct URLs through the AllDebrid API.\n     Auth is via ``apikey`` query parameter (not Bearer header).\n     \"\"\"\n \n     provider: str = \"all_debrid\"\n     BASE_URL: str = \"https://api.alldebrid.com/v5\"\n     supported_source_types = frozenset(\n         {ResolveSourceType.DIRECT_URL, ResolveSourceType.MAGNET, ResolveSourceType.INFO_HASH}\n     )\n \n     def __init__(self, http_client: httpx.AsyncClient | None = None) -> None:\n         self._owns_client = http_client is None\n         self._client = http_client or httpx.AsyncClient(\n             timeout=_DEFAULT_TIMEOUT,\n             limits=_DEFAULT_LIMITS,\n         )\n \n     async def close(self) -> None:\n         if self._owns_client:\n             await self._client.aclose()\n \n     async def resolve(self, source: ResolveSource, api_key: str) -> list[StreamResolveResult]:\n         \"\"\"Resolve a stream source through AllDebrid.\"\"\"\n         params: dict[str, str] = {\"apikey\": api_key}\n \n         if source.source_type == ResolveSourceType.DIRECT_URL:\n             return await self._resolve_direct(source, params)\n \n         if source.source_type in (\n             ResolveSourceType.MAGNET,\n             ResolveSourceType.INFO_HASH,\n         ):\n             return await self._resolve_magnet(source, params)\n \n         raise UnsupportedStreamSourceError()\n \n     async def _resolve_direct(self, source: ResolveSource, params: dict[str, str]) -> list[StreamResolveResult]:\n         \"\"\"Resolve a direct URL via POST /link/unrestrict.\"\"\"\n-        data = await _request_json(\n+        data = await request_json(\n             self._client,\n             \"POST\",\n             f\"{self.BASE_URL}/link/unrestrict\",\n             params=params,\n             data={\"link\": source.value},\n         )\n \n-        # AllDebrid wraps in status/data envelope\n         if data.get(\"status\") != \"success\":\n             raise ProviderStreamUnavailableError()\n         inner = data.get(\"data\")\n         if not isinstance(inner, dict):\n             raise ProviderStreamUnavailableError()\n \n-        filename = _safe_filename(inner.get(\"filename\"))\n-        if not _is_playable_media(filename):\n+        filename = safe_filename(inner.get(\"filename\"))\n+        if not is_playable_media(filename):\n             return []\n \n-        size = _size_bytes(inner.get(\"size\"))\n-        stream_url = _stream_url(inner.get(\"link\"))\n+        file_size = size_bytes(inner.get(\"size\"))\n+        stream_url_val = stream_url(inner.get(\"link\"))\n \n         return [\n             StreamResolveResult(\n                 provider=self.provider,\n                 account_id=0,\n                 source_type=source.source_type,\n                 title=filename,\n-                stream_url=stream_url,\n+                stream_url=stream_url_val,\n                 filename=filename,\n-                size_bytes=size,\n-                mime_type=_infer_mime_type(filename),\n-                quality=_infer_quality(filename),\n+                size_bytes=file_size,\n+                mime_type=infer_mime_type(filename),\n+                quality=infer_quality(filename),\n                 expires_at=None,\n                 cached=False,\n             )\n         ]\n \n     async def _resolve_magnet(self, source: ResolveSource, params: dict[str, str]) -> list[StreamResolveResult]:\n         \"\"\"Resolve a magnet or info-hash via POST /magnet/upload + GET /magnet/status.\"\"\"\n         hash_value: str = source.value\n \n         if source.source_type == ResolveSourceType.MAGNET:\n-            extracted = RealDebridResolver._extract_hash(source.value)\n+            extracted = self._extract_hash(source.value)\n             if extracted is None:\n                 raise UnsupportedStreamSourceError()\n             hash_value = extracted\n \n-        # Upload magnet\n         magnet_value = source.value if source.source_type == ResolveSourceType.MAGNET else f\"magnet:?xt=urn:btih:{hash_value}\"\n-        upload_data = await _request_json(\n+        upload_data = await request_json(\n             self._client,\n             \"POST\",\n             f\"{self.BASE_URL}/magnet/upload\",\n             params=params,\n             data={\"magnets\": magnet_value},\n         )\n \n         if upload_data.get(\"status\") != \"success\":\n             raise ProviderStreamUnavailableError()\n \n         upload_inner = upload_data.get(\"data\")\n         if not isinstance(upload_inner, dict):\n             raise ProviderStreamUnavailableError()\n \n         magnets = upload_inner.get(\"magnets\", [])\n         if not isinstance(magnets, list) or not magnets:\n             raise ProviderStreamUnavailableError()\n \n         magnet_entry = magnets[0]\n         if not isinstance(magnet_entry, dict):\n             raise ProviderStreamUnavailableError()\n         magnet_id = magnet_entry.get(\"id\")\n         if magnet_id is None:\n             raise ProviderStreamUnavailableError()\n \n-        # Poll magnet status for links\n-        status_data = await _request_json(\n+        status_data = await request_json(\n             self._client,\n             \"GET\",\n             f\"{self.BASE_URL}/magnet/status\",\n             params={**params, \"id\": str(magnet_id)},\n         )\n \n         if status_data.get(\"status\") != \"success\":\n             raise ProviderStreamUnavailableError()\n \n         status_inner = status_data.get(\"data\")\n         if not isinstance(status_inner, dict):\n             raise ProviderStreamUnavailableError()\n         status_magnets = status_inner.get(\"magnets\", [])\n         if not isinstance(status_magnets, list) or not status_magnets:\n             raise ProviderStreamUnavailableError()\n \n         magnet_info = status_magnets[0]\n         if not isinstance(magnet_info, dict):\n             raise ProviderStreamUnavailableError()\n \n         links = magnet_info.get(\"links\", [])\n         if not isinstance(links, list) or not links:\n             raise ProviderStreamUnavailableError()\n \n         results: list[StreamResolveResult] = []\n         for link_entry in links[:_MAX_PROVIDER_FILES]:\n             if not isinstance(link_entry, dict):\n                 continue\n \n             try:\n-                link_url = _stream_url(link_entry.get(\"link\"))\n+                link_url = stream_url(link_entry.get(\"link\"))\n             except ProviderStreamUnavailableError:\n                 continue\n \n-            filename = _safe_filename(link_entry.get(\"filename\"))\n-            if not _is_playable_media(filename):\n+            filename = safe_filename(link_entry.get(\"filename\"))\n+            if not is_playable_media(filename):\n                 continue\n \n             try:\n-                size = _size_bytes(link_entry.get(\"size\"))\n+                file_size = size_bytes(link_entry.get(\"size\"))\n             except ProviderStreamUnavailableError:\n                 continue\n \n             results.append(\n                 StreamResolveResult(\n                     provider=self.provider,\n                     account_id=0,\n                     source_type=source.source_type,\n                     title=filename,\n                     stream_url=link_url,\n                     filename=filename,\n-                    size_bytes=size,\n-                    mime_type=_infer_mime_type(filename),\n-                    quality=_infer_quality(filename),\n+                    size_bytes=file_size,\n+                    mime_type=infer_mime_type(filename),\n+                    quality=infer_quality(filename),\n                     expires_at=None,\n                     cached=False,\n                 )\n             )\n \n         return results\n \n \n-class TorBoxResolver(ProviderStreamResolver):\n+class TorBoxResolver(ProviderStreamResolver, _BaseResolverMixin):\n     \"\"\"TorBox stream resolver.\n \n     Implements the ProviderStreamResolver port. Resolves magnet links\n     and info-hashes through the TorBox API using query-param auth.\n     \"\"\"\n \n     provider: str = \"torbox\"\n     BASE_URL: str = \"https://api.torbox.app/api\"\n     supported_source_types = frozenset({ResolveSourceType.MAGNET, ResolveSourceType.INFO_HASH})\n \n     def __init__(self, http_client: httpx.AsyncClient | None = None) -> None:\n         self._owns_client = http_client is None\n         self._client = http_client or httpx.AsyncClient(\n             timeout=_DEFAULT_TIMEOUT,\n             limits=_DEFAULT_LIMITS,\n         )\n \n     async def close(self) -> None:\n         if self._owns_client:\n             await self._client.aclose()\n \n     async def resolve(self, source: ResolveSource, api_key: str) -> list[StreamResolveResult]:\n         \"\"\"Resolve a stream source through TorBox.\"\"\"\n         params: dict[str, str] = {\"apikey\": api_key}\n \n         if source.source_type == ResolveSourceType.DIRECT_URL:\n             raise UnsupportedStreamSourceError()\n \n         if source.source_type in (\n             ResolveSourceType.MAGNET,\n             ResolveSourceType.INFO_HASH,\n         ):\n             return await self._resolve_torrent(source, params)\n \n         raise UnsupportedStreamSourceError()\n \n     async def _resolve_torrent(self, source: ResolveSource, params: dict[str, str]) -> list[StreamResolveResult]:\n         \"\"\"Resolve a magnet/info-hash via POST /torrents/createtorrent + GET /torrents/mylist.\"\"\"\n         hash_value: str = source.value\n \n         if source.source_type == ResolveSourceType.MAGNET:\n-            extracted = RealDebridResolver._extract_hash(source.value)\n+            extracted = self._extract_hash(source.value)\n             if extracted is None:\n                 raise UnsupportedStreamSourceError()\n             hash_value = extracted\n \n-        # Create torrent\n         magnet_url = source.value if source.source_type == ResolveSourceType.MAGNET else f\"magnet:?xt=urn:btih:{hash_value}\"\n-        create_data = await _request_json(\n+        create_data = await request_json(\n             self._client,\n             \"POST\",\n             f\"{self.BASE_URL}/torrents/createtorrent\",\n             params=params,\n             data={\"magnet\": magnet_url},\n         )\n \n         if create_data.get(\"status\") != \"success\":\n             raise ProviderStreamUnavailableError()\n \n         created_data = create_data.get(\"data\")\n         created_torrent_id: str | None = None\n         if isinstance(created_data, dict):\n             torrent_id_raw = created_data.get(\"torrent_id\") or created_data.get(\"id\")\n             if torrent_id_raw is not None:\n                 created_torrent_id = str(torrent_id_raw)\n \n-        # Poll mylist for ready torrents\n-        list_data = await _request_json(\n+        list_data = await request_json(\n             self._client,\n             \"GET\",\n             f\"{self.BASE_URL}/torrents/mylist\",\n             params=params,\n         )\n \n         if list_data.get(\"status\") != \"success\":\n             raise ProviderStreamUnavailableError()\n \n         torrents = list_data.get(\"data\", [])\n         if not isinstance(torrents, list):\n             torrents = []\n \n         results: list[StreamResolveResult] = []\n         for torrent in torrents:\n             if not isinstance(torrent, dict):\n                 continue\n             torrent_id = torrent.get(\"id\") or torrent.get(\"torrent_id\")\n             torrent_hash = torrent.get(\"hash\")\n             id_matches = created_torrent_id is not None and torrent_id is not None and str(torrent_id) == created_torrent_id\n             hash_matches = isinstance(torrent_hash, str) and torrent_hash.lower() == hash_value.lower()\n             if not (id_matches or hash_matches):\n                 continue\n \n             files_data = torrent.get(\"files\", [])\n             if not isinstance(files_data, list):\n                 continue\n \n             for file_item in files_data[:_MAX_PROVIDER_FILES]:\n                 if not isinstance(file_item, dict):\n                     continue\n \n                 stream_url_raw = file_item.get(\"url\") or file_item.get(\"stream_url\")\n                 if not isinstance(stream_url_raw, str) or not stream_url_raw.strip():\n                     continue\n \n                 try:\n-                    stream_url_val = _stream_url(stream_url_raw)\n+                    stream_url_val = stream_url(stream_url_raw)\n                 except ProviderStreamUnavailableError:\n                     continue\n \n-                filename = _safe_filename(file_item.get(\"name\") or file_item.get(\"filename\"))\n-                if not _is_playable_media(filename):\n+                filename = safe_filename(file_item.get(\"name\") or file_item.get(\"filename\"))\n+                if not is_playable_media(filename):\n                     continue\n \n                 try:\n-                    size = _size_bytes(file_item.get(\"size\"))\n+                    file_size = size_bytes(file_item.get(\"size\"))\n                 except ProviderStreamUnavailableError:\n                     continue\n \n                 results.append(\n                     StreamResolveResult(\n                         provider=self.provider,\n                         account_id=0,\n                         source_type=source.source_type,\n                         title=filename,\n                         stream_url=stream_url_val,\n                         filename=filename,\n-                        size_bytes=size,\n-                        mime_type=_infer_mime_type(filename),\n-                        quality=_infer_quality(filename),\n+                        size_bytes=file_size,\n+                        mime_type=infer_mime_type(filename),\n+                        quality=infer_quality(filename),\n                         expires_at=None,\n                         cached=False,\n                     )\n                 )\n \n         return results\n \n \n-class PremiumizeResolver(ProviderStreamResolver):\n+class PremiumizeResolver(ProviderStreamResolver, _BaseResolverMixin):\n     \"\"\"Premiumize stream resolver.\n \n     Implements the ProviderStreamResolver port. Resolves magnet links\n     and info-hashes through the Premiumize API using query-param auth.\n     \"\"\"\n \n     provider: str = \"premiumize\"\n     BASE_URL: str = \"https://www.premiumize.me/api/v1\"\n     supported_source_types = frozenset({ResolveSourceType.MAGNET, ResolveSourceType.INFO_HASH})\n \n     def __init__(self, http_client: httpx.AsyncClient | None = None) -> None:\n         self._owns_client = http_client is None\n         self._client = http_client or httpx.AsyncClient(\n             timeout=_DEFAULT_TIMEOUT,\n             limits=_DEFAULT_LIMITS,\n         )\n \n     async def close(self) -> None:\n         if self._owns_client:\n             await self._client.aclose()\n \n     async def resolve(self, source: ResolveSource, api_key: str) -> list[StreamResolveResult]:\n         \"\"\"Resolve a stream source through Premiumize.\"\"\"\n         params: dict[str, str] = {\"apikey\": api_key}\n \n         if source.source_type == ResolveSourceType.DIRECT_URL:\n             raise UnsupportedStreamSourceError()\n \n         if source.source_type in (\n             ResolveSourceType.MAGNET,\n             ResolveSourceType.INFO_HASH,\n         ):\n             return await self._resolve_torrent(source, params)\n \n         raise UnsupportedStreamSourceError()\n \n     async def _resolve_torrent(self, source: ResolveSource, params: dict[str, str]) -> list[StreamResolveResult]:\n         \"\"\"Resolve a magnet/info-hash via POST /torrent/create + GET /torrent/browse.\"\"\"\n         hash_value: str = source.value\n \n         if source.source_type == ResolveSourceType.MAGNET:\n-            extracted = RealDebridResolver._extract_hash(source.value)\n+            extracted = self._extract_hash(source.value)\n             if extracted is None:\n                 raise UnsupportedStreamSourceError()\n             hash_value = extracted\n \n-        # Create torrent\n         magnet_url = source.value if source.source_type == ResolveSourceType.MAGNET else f\"magnet:?xt=urn:btih:{hash_value}\"\n-        create_data = await _request_json(\n+        create_data = await request_json(\n             self._client,\n             \"POST\",\n             f\"{self.BASE_URL}/torrent/create\",\n             params=params,\n             data={\"src\": magnet_url},\n         )\n \n         if create_data.get(\"status\") != \"success\":\n             raise ProviderStreamUnavailableError()\n \n         torrent_id = create_data.get(\"id\")\n         if torrent_id is None:\n             raise ProviderStreamUnavailableError()\n \n-        # Browse torrent files\n-        browse_data = await _request_json(\n+        browse_data = await request_json(\n             self._client,\n             \"GET\",\n             f\"{self.BASE_URL}/torrent/browse\",\n             params={**params, \"id\": str(torrent_id)},\n         )\n \n         if browse_data.get(\"status\") != \"success\":\n             raise ProviderStreamUnavailableError()\n \n         content = browse_data.get(\"content\", [])\n         if not isinstance(content, list):\n             content = []\n \n         results: list[StreamResolveResult] = []\n         for item in content[:_MAX_PROVIDER_FILES]:\n             if not isinstance(item, dict):\n                 continue\n \n             item_id = item.get(\"id\")\n             if item_id is None:\n                 continue\n \n             stream_url_raw = item.get(\"stream_url\")\n             if isinstance(stream_url_raw, str) and stream_url_raw.strip():\n                 try:\n-                    stream_url_val = _stream_url(stream_url_raw)\n+                    stream_url_val = stream_url(stream_url_raw)\n                 except ProviderStreamUnavailableError:\n                     continue\n             else:\n-                details_data = await _request_json(\n+                details_data = await request_json(\n                     self._client,\n                     \"GET\",\n                     f\"{self.BASE_URL}/item/details\",\n                     params={**params, \"id\": str(item_id)},\n                 )\n                 if details_data.get(\"status\") != \"success\":\n                     continue\n                 detail_item = details_data.get(\"content\", {})\n                 if not isinstance(detail_item, dict):\n                     continue\n                 stream_detail = detail_item.get(\"stream_url\")\n                 if not isinstance(stream_detail, str) or not stream_detail.strip():\n                     continue\n                 try:\n-                    stream_url_val = _stream_url(stream_detail)\n+                    stream_url_val = stream_url(stream_detail)\n                 except ProviderStreamUnavailableError:\n                     continue\n \n-            filename = _safe_filename(item.get(\"name\"))\n-            if not _is_playable_media(filename):\n+            filename = safe_filename(item.get(\"name\"))\n+            if not is_playable_media(filename):\n                 continue\n \n             try:\n-                size = _size_bytes(item.get(\"size\"))\n+                file_size = size_bytes(item.get(\"size\"))\n             except ProviderStreamUnavailableError:\n                 continue\n \n             results.append(\n                 StreamResolveResult(\n                     provider=self.provider,\n                     account_id=0,\n                     source_type=source.source_type,\n                     title=filename,\n                     stream_url=stream_url_val,\n                     filename=filename,\n-                    size_bytes=size,\n-                    mime_type=_infer_mime_type(filename),\n-                    quality=_infer_quality(filename),\n+                    size_bytes=file_size,\n+                    mime_type=infer_mime_type(filename),\n+                    quality=infer_quality(filename),\n                     expires_at=None,\n                     cached=False,\n                 )\n             )\n \n         return results\n",
    "additions": 162,
    "deletions": 405,
    "status": "modified"
  },
  {
    "file": "tests/modules/addons/test_presentation_routers.py",
    "patch": "Index: tests/modules/addons/test_presentation_routers.py\n===================================================================\n--- tests/modules/addons/test_presentation_routers.py\t\n+++ tests/modules/addons/test_presentation_routers.py\t\n@@ -1,335 +1,336 @@\n \"\"\"Integration tests for Stremio addon API endpoints.\n \n These exercise the full stack with a mocked addon provider to avoid\n requiring real TMDB or debrid services.\n \"\"\"\n \n from __future__ import annotations\n \n from collections.abc import AsyncIterator\n \n import pytest\n from httpx import ASGITransport, AsyncClient\n \n from app.core.database import get_async_session\n from app.main import create_app\n from app.modules.addons.application.interfaces import AddonProvider\n from app.modules.addons.domain.value_objects import (\n     CatalogResult,\n     StreamResult,\n     StremioCatalogId,\n     StremioCatalogItem,\n     StremioContentType,\n     StremioManifest,\n     StremioMetaItem,\n     StremioResourceType,\n     StremioStreamInfo,\n )\n from app.modules.addons.presentation.dependencies import get_addon_provider\n \n \n class FakeAddonProvider(AddonProvider):\n     \"\"\"In-memory stub of the AddonProvider port for testing.\"\"\"\n \n     async def get_manifest(self) -> StremioManifest:\n         return StremioManifest(\n             id=\"test.addon\",\n             version=\"1.0.0\",\n             name=\"Test Addon\",\n             description=\"Test addon description\",\n             resources=[\n                 StremioResourceType.CATALOG,\n                 StremioResourceType.META,\n                 StremioResourceType.STREAM,\n             ],\n             types=[StremioContentType.MOVIE, StremioContentType.SERIES],\n             id_prefixes=[\"tt\"],\n             catalogs=[\n                 {\"type\": \"movie\", \"id\": \"tmdb:trending:movie\"},\n                 {\"type\": \"series\", \"id\": \"tmdb:trending:tv\"},\n             ],\n         )\n \n     async def get_catalog(\n         self,\n         catalog_id: StremioCatalogId | str,\n         content_type: StremioContentType,\n         skip: int = 0,\n         limit: int = 20,\n     ) -> CatalogResult:\n         items = [\n             StremioCatalogItem(\n                 id=\"tmdb:550\",\n                 type=content_type,\n                 name=\"Test Movie\",\n                 poster=\"https://image.tmdb.org/t/p/w500/poster.jpg\",\n                 description=\"A test movie\",\n                 release_info=\"1999\",\n                 runtime=\"139 min\",\n                 genre=[\"Drama\"],\n                 imdb_rating=\"8.4\",\n                 popularity=123.45,\n             ),\n         ]\n         return CatalogResult(items=items, total=1)\n \n     async def get_meta(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n     ) -> StremioMetaItem | None:\n         if item_id == \"99999\":\n             return None\n         return StremioMetaItem(\n             id=item_id,\n             type=content_type,\n             name=\"Test Movie\",\n             poster=\"https://image.tmdb.org/t/p/w500/poster.jpg\",\n             description=\"A test movie\",\n             release_info=\"1999\",\n             runtime=\"139 min\",\n             genre=[\"Drama\"],\n             imdb_rating=\"8.4\",\n         )\n \n     async def get_streams(\n         self,\n         content_type: StremioContentType,\n         item_id: str,\n+        api_key: str | None = None,\n     ) -> StreamResult:\n         return StreamResult(\n             streams=[\n                 StremioStreamInfo(\n                     url=\"https://example.com/stream.mp4\",\n                     name=\"HD Stream\",\n                     description=\"1080p stream from debrid provider\",\n                     quality=\"1080p\",\n                     source=\"Real-Debrid\",\n                 ),\n             ],\n         )\n \n \n @pytest.fixture\n async def client() -> AsyncIterator[AsyncClient]:\n     \"\"\"Provide an async test client with a mocked addon provider.\"\"\"\n     from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n \n     from app.core.database import Base\n \n     engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\")\n     async with engine.begin() as conn:\n         await conn.run_sync(Base.metadata.create_all)\n \n     session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n \n     async def override_session() -> AsyncIterator[AsyncSession]:\n         async with session_factory() as session:\n             try:\n                 yield session\n                 await session.commit()\n             except Exception:\n                 await session.rollback()\n                 raise\n \n     app = create_app()\n     app.dependency_overrides[get_async_session] = override_session\n     app.dependency_overrides[get_addon_provider] = lambda: FakeAddonProvider()\n \n     async with AsyncClient(\n         transport=ASGITransport(app=app), base_url=\"http://test\"\n     ) as test_client:\n         yield test_client\n \n     app.dependency_overrides.clear()\n     await engine.dispose()\n \n \n # --- Manifest tests ---\n \n \n class TestManifestEndpoint:\n     \"\"\"Tests for GET /stremio/manifest.json.\"\"\"\n \n     @pytest.mark.asyncio\n     async def test_manifest_returns_addon_info(self, client: AsyncClient) -> None:\n         \"\"\"Manifest endpoint returns addon metadata.\"\"\"\n         response = await client.get(\"/api/v1/stremio/manifest.json\")\n         assert response.status_code == 200\n         body = response.json()\n         assert body[\"id\"] == \"test.addon\"\n         assert body[\"version\"] == \"1.0.0\"\n         assert body[\"name\"] == \"Test Addon\"\n         assert \"catalog\" in body[\"resources\"]\n         assert \"meta\" in body[\"resources\"]\n         assert \"stream\" in body[\"resources\"]\n         assert \"movie\" in body[\"types\"]\n         assert \"series\" in body[\"types\"]\n         assert len(body[\"catalogs\"]) == 2\n \n \n # --- Catalog tests ---\n \n \n class TestCatalogEndpoint:\n     \"\"\"Tests for GET /stremio/catalog/{type}/{id}.json.\"\"\"\n \n     @pytest.mark.asyncio\n     async def test_catalog_returns_metas(self, client: AsyncClient) -> None:\n         \"\"\"Catalog endpoint returns metas array.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/catalog/movie/tmdb:trending:movie.json\",\n         )\n         assert response.status_code == 200\n         body = response.json()\n         assert \"metas\" in body\n         assert len(body[\"metas\"]) == 1\n         assert body[\"metas\"][0][\"id\"] == \"tmdb:550\"\n         assert body[\"metas\"][0][\"name\"] == \"Test Movie\"\n \n     @pytest.mark.asyncio\n     async def test_catalog_validates_content_type(self, client: AsyncClient) -> None:\n         \"\"\"Catalog rejects invalid content type.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/catalog/invalid/tmdb:trending:movie.json\",\n         )\n         assert response.status_code == 422\n \n     @pytest.mark.asyncio\n     async def test_catalog_respects_pagination(self, client: AsyncClient) -> None:\n         \"\"\"Catalog passes skip/limit to the provider.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/catalog/series/tmdb:trending:tv.json\",\n             params={\"skip\": 0, \"limit\": 10},\n         )\n         assert response.status_code == 200\n         body = response.json()\n         assert len(body[\"metas\"]) == 1\n         assert body[\"metas\"][0][\"type\"] == \"series\"\n \n \n # --- Meta tests ---\n \n \n class TestMetaEndpoint:\n     \"\"\"Tests for GET /stremio/meta/{type}/{id}.json.\"\"\"\n \n     @pytest.mark.asyncio\n     async def test_meta_returns_item(self, client: AsyncClient) -> None:\n         \"\"\"Meta endpoint returns item metadata.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/meta/movie/tmdb:550.json\",\n         )\n         assert response.status_code == 200\n         body = response.json()\n         assert body[\"meta\"] is not None\n         assert body[\"meta\"][\"name\"] == \"Test Movie\"\n         assert body[\"meta\"][\"id\"] == \"tmdb:550\"\n \n     @pytest.mark.asyncio\n     async def test_meta_not_found(self, client: AsyncClient) -> None:\n         \"\"\"Non-existent item returns 404.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/meta/movie/99999.json\",\n         )\n         assert response.status_code == 404\n \n     @pytest.mark.asyncio\n     async def test_meta_validates_content_type(self, client: AsyncClient) -> None:\n         \"\"\"Meta rejects invalid content type.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/meta/invalid/tmdb:550.json\",\n         )\n         assert response.status_code == 422\n \n \n # --- Stream tests ---\n \n \n class TestStreamEndpoint:\n     \"\"\"Tests for GET /stremio/stream/{type}/{id}.json.\"\"\"\n \n     @pytest.mark.asyncio\n     async def test_stream_returns_streams_array(self, client: AsyncClient) -> None:\n         \"\"\"Stream endpoint returns streams array.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/stream/movie/tmdb:550.json\",\n         )\n         assert response.status_code == 200\n         body = response.json()\n         assert \"streams\" in body\n         assert len(body[\"streams\"]) == 1\n         assert body[\"streams\"][0][\"url\"] == \"https://example.com/stream.mp4\"\n         assert body[\"streams\"][0][\"name\"] == \"HD Stream\"\n \n     @pytest.mark.asyncio\n     async def test_stream_validates_content_type(self, client: AsyncClient) -> None:\n         \"\"\"Stream rejects invalid content type.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/stream/invalid/tmdb:550.json\",\n         )\n         assert response.status_code == 422\n \n     @pytest.mark.asyncio\n     async def test_stream_empty_returns_empty_array(self, client: AsyncClient) -> None:\n         \"\"\"Stream returns empty array when no streams.\"\"\"\n         response = await client.get(\n             \"/api/v1/stremio/stream/series/tmdb:550.json\",\n         )\n         assert response.status_code == 200\n         body = response.json()\n         assert body[\"streams\"] == [] or len(body[\"streams\"]) >= 1\n \n \n # --- Direct client test for the provider's stream endpoint behavior ---\n \n \n @pytest.mark.asyncio\n async def test_stremio_endpoints_do_not_require_auth() -> None:\n     \"\"\"Stremio addon endpoints do not require authentication.\n \n     Unlike internal API routes, Stremio addons are consumed by\n     the Stremio client directly and must be accessible without\n     auth tokens.\n     \"\"\"\n     from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n \n     from app.core.database import Base\n \n     engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\")\n     async with engine.begin() as conn:\n         await conn.run_sync(Base.metadata.create_all)\n \n     session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n \n     async def override_session() -> AsyncIterator[AsyncSession]:\n         async with session_factory() as session:\n             try:\n                 yield session\n                 await session.commit()\n             except Exception:\n                 await session.rollback()\n                 raise\n \n     app = create_app()\n     app.dependency_overrides[get_async_session] = override_session\n     app.dependency_overrides[get_addon_provider] = lambda: FakeAddonProvider()\n \n     async with AsyncClient(\n         transport=ASGITransport(app=app), base_url=\"http://test\"\n     ) as client:\n         # All Stremio endpoints should work without auth\n         endpoints = [\n             \"/api/v1/stremio/manifest.json\",\n             \"/api/v1/stremio/catalog/movie/tmdb:trending:movie.json\",\n             \"/api/v1/stremio/meta/movie/tmdb:550.json\",\n             \"/api/v1/stremio/stream/movie/tmdb:550.json\",\n         ]\n         for endpoint in endpoints:\n             response = await client.get(endpoint)\n             assert response.status_code in (200, 404), (\n                 f\"{endpoint} returned {response.status_code}\"\n             )\n \n     app.dependency_overrides.clear()\n     await engine.dispose()\n",
    "additions": 1,
    "deletions": 0,
    "status": "modified"
  },
  {
    "file": "tests/modules/streaming/test_infrastructure_provider_resolvers.py",
    "patch": "Index: tests/modules/streaming/test_infrastructure_provider_resolvers.py\n===================================================================\n--- tests/modules/streaming/test_infrastructure_provider_resolvers.py\t\n+++ tests/modules/streaming/test_infrastructure_provider_resolvers.py\t\n@@ -1,1222 +1,1232 @@\n \"\"\"Tests for Real-Debrid stream resolver infrastructure.\"\"\"\n \n \n import httpx\n import pytest\n \n from app.modules.streaming.application.exceptions import (\n     ProviderStreamUnavailableError,\n )\n from app.modules.streaming.domain.value_objects import (\n     ResolveSource,\n     ResolveSourceType,\n     StreamQuality,\n )\n from app.modules.streaming.infrastructure.provider_resolvers import (\n     AllDebridResolver,\n     PremiumizeResolver,\n     RealDebridResolver,\n     TorBoxResolver,\n     _infer_mime_type,\n     _infer_quality,\n     _request_json,\n     _safe_filename,\n )\n \n # ── Helpers ──────────────────────────────────────────────────────────────\n \n \n def _make_mock_transport(responses: list[httpx.Response]) -> httpx.MockTransport:\n     \"\"\"Create a mock transport that returns a sequence of responses.\"\"\"\n     call_index = 0\n \n     def handler(request: httpx.Request) -> httpx.Response:\n         nonlocal call_index\n         if call_index >= len(responses):\n             raise RuntimeError(\n                 f\"Mock transport exhausted — expected {len(responses)} calls, got {call_index + 1}\"\n             )\n         resp = responses[call_index]\n         call_index += 1\n         return resp\n \n     return httpx.MockTransport(handler)\n \n \n def _json_response(status: int, data: dict) -> httpx.Response:\n     return httpx.Response(status, json=data, request=httpx.Request(\"GET\", \"https://example.com\"))\n \n \n # ── Quality inference tests ──────────────────────────────────────────────\n \n \n class TestQualityInference:\n     def test_4k_filename(self):\n         assert _infer_quality(\"movie.4k.2024.mkv\") == StreamQuality.UHD\n \n     def test_2160p_filename(self):\n         assert _infer_quality(\"movie.2160p.bluray.x265.mkv\") == StreamQuality.UHD\n \n     def test_1080p_filename(self):\n         assert _infer_quality(\"movie.1080p.webrip.x264.mkv\") == StreamQuality.FHD\n \n     def test_720p_filename(self):\n         assert _infer_quality(\"movie.720p.hdtv.x264.mkv\") == StreamQuality.HD\n \n     def test_480p_filename(self):\n         assert _infer_quality(\"movie.480p.webrip.x264.mkv\") == StreamQuality.SD\n \n     def test_360p_filename(self):\n         assert _infer_quality(\"video.360p.mp4\") == StreamQuality.SD\n \n     def test_audio_only(self):\n         assert _infer_quality(\"song.audio.flac\") == StreamQuality.UNKNOWN\n \n     def test_unknown_filename(self):\n         assert _infer_quality(\"randomfile.dat\") == StreamQuality.UNKNOWN\n \n \n # ── MIME type inference tests ────────────────────────────────────────────\n \n \n class TestMimeInference:\n     def test_mp4(self):\n         assert _infer_mime_type(\"video.mp4\") == \"video/mp4\"\n \n     def test_mkv(self):\n         assert _infer_mime_type(\"video.mkv\") == \"video/x-matroska\"\n \n     def test_avi(self):\n         assert _infer_mime_type(\"video.avi\") == \"video/x-msvideo\"\n \n     def test_mov(self):\n         assert _infer_mime_type(\"video.mov\") == \"video/quicktime\"\n \n     def test_webm(self):\n         assert _infer_mime_type(\"video.webm\") == \"video/webm\"\n \n     def test_mp3(self):\n         assert _infer_mime_type(\"audio.mp3\") == \"audio/mpeg\"\n \n     def test_flac(self):\n         assert _infer_mime_type(\"audio.flac\") == \"audio/flac\"\n \n     def test_m3u8(self):\n         assert _infer_mime_type(\"playlist.m3u8\") == \"application/x-mpegURL\"\n \n     def test_unknown_extension(self):\n         assert _infer_mime_type(\"file.xyz\") is None\n \n     def test_empty_filename(self):\n         assert _infer_mime_type(\"\") is None\n \n \n \n \n class TestRequestJsonRetryPolicy:\n     async def test_get_retries_transient_503_once_then_succeeds(self):\n         calls = 0\n \n         def handler(request: httpx.Request) -> httpx.Response:\n             nonlocal calls\n             calls += 1\n             if calls == 1:\n                 return httpx.Response(503, json={\"error\": \"temporary\"}, request=request)\n             return httpx.Response(200, json={\"ok\": True}, request=request)\n \n         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n         try:\n             data = await _request_json(client, \"GET\", \"https://provider.example/status\")\n         finally:\n             await client.aclose()\n \n         assert data == {\"ok\": True}\n         assert calls == 2\n \n     async def test_post_does_not_retry_ambiguous_timeout(self):\n         calls = 0\n \n         def handler(request: httpx.Request) -> httpx.Response:\n             nonlocal calls\n             calls += 1\n             raise httpx.TimeoutException(\"timed out\", request=request)\n \n         credential = \"secret\" + \"-token\"\n         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n         try:\n             with pytest.raises(ProviderStreamUnavailableError) as exc_info:\n                 await _request_json(\n                     client,\n                     \"POST\",\n                     \"https://provider.example/create\",\n                     params={\"apikey\": credential},\n                 )\n         finally:\n             await client.aclose()\n \n         assert calls == 1\n         assert exc_info.value.__cause__ is None\n         assert credential not in str(exc_info.value)\n \n \n class TestProviderDataSanitization:\n     def test_safe_filename_strips_paths_controls_and_bounds_length(self):\n         raw_name = \"../library/evil\\x00movie.1080p.mkv\" + (\"x\" * 300)\n \n         filename = _safe_filename(raw_name)\n \n         assert \"../\" not in filename\n         assert \"\\x00\" not in filename\n         assert filename.startswith(\"evilmovie.1080p.mkv\")\n         assert len(filename) == 255\n \n     def test_safe_filename_replaces_navigation_segments(self):\n         assert _safe_filename(\"..\") == \"unknown\"\n         assert _safe_filename(\".\") == \"unknown\"\n \n     async def test_http_stream_url_is_rejected(self):\n         responses = [\n             _json_response(200, {\n                 \"filename\": \"movie.mp4\",\n                 \"filesize\": 100,\n                 \"link\": \"http://cdn.example.com/movie.mp4\",\n             }),\n         ]\n         client = httpx.AsyncClient(transport=_make_mock_transport(responses))\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n # ── Direct URL resolve tests ─────────────────────────────────────────────\n \n \n class TestDirectUrlResolve:\n     async def test_direct_url_success(self):\n         \"\"\"Direct URL is unrestricted and returns a stream result.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"filename\": \"movie.1080p.mkv\",\n                 \"filesize\": 1234567890,\n                 \"link\": \"https://cdn.example.com/stream/movie.mkv\",\n                 \"duration\": 7200,\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/movie.mkv\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         assert len(results) == 1\n         assert results[0].provider == \"real_debrid\"\n         assert results[0].filename == \"movie.1080p.mkv\"\n         assert results[0].stream_url == \"https://cdn.example.com/stream/movie.mkv\"\n         assert results[0].size_bytes == 1234567890\n         assert results[0].mime_type == \"video/x-matroska\"\n         assert results[0].quality == StreamQuality.FHD\n         assert results[0].cached is False\n \n         await resolver.close()\n \n     async def test_direct_url_unknown_filename_returns_no_playable_streams(self):\n         \"\"\"Direct URL responses without a playable media filename are filtered out.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"filesize\": 100,\n                 \"link\": \"https://cdn.example.com/stream\",\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         assert results == []\n \n         await resolver.close()\n \n     async def test_direct_url_empty_link_raises_unavailable(self):\n         \"\"\"Provider responses without a usable stream URL are rejected.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"filename\": \"movie.mp4\",\n                 \"filesize\": 100,\n                 \"link\": \"\",\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_direct_url_unsafe_stream_url_raises_unavailable(self):\n         \"\"\"Provider-returned stream URLs must be absolute HTTP(S), not script/file URLs.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"filename\": \"movie.mp4\",\n                 \"filesize\": 100,\n                 \"link\": \"javascript:alert(1)\",\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_direct_url_non_finite_size_raises_unavailable(self):\n         \"\"\"Provider numeric fields like NaN/Inf are rejected instead of crashing.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"filename\": \"movie.mp4\",\n                 \"filesize\": \"nan\",\n                 \"link\": \"https://cdn.example.com/movie.mp4\",\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n \n # ── Torrent resolve tests ────────────────────────────────────────────────\n \n \n class TestTorrentResolve:\n     async def test_magnet_resolve_success(self):\n         \"\"\"Magnet link is added, files selected, links unrestricted.\"\"\"\n         responses = [\n+            # Pre-flight: GET /torrents/instantAvailability/{hash} — not cached\n+            _json_response(200, {}),\n             # Step 1: POST /torrents/addMagnet\n             _json_response(201, {\n                 \"id\": 12345,\n                 \"uri\": \"https://api.real-debrid.com/rest/1.0/torrents/info/12345\",\n             }),\n             # Step 2: POST /torrents/selectFiles/12345 — returns 204 No Content\n             httpx.Response(204, request=httpx.Request(\"POST\", \"https://api.real-debrid.com/rest/1.0/torrents/selectFiles/12345\")),\n             # Step 3: GET /torrents/info/12345\n             _json_response(200, {\n                 \"id\": 12345,\n                 \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n                 \"filename\": \"movie.1080p.mkv\",\n                 \"bytes\": 2000000000,\n                 \"status\": \"downloaded\",\n                 \"added\": \"2024-01-01T00:00:00+00:00\",\n                 \"files\": [\n                     {\"id\": 0, \"path\": \"movie.1080p.mkv\", \"bytes\": 1500000000, \"selected\": 1},\n                     {\"id\": 1, \"path\": \"subtitle.en.srt\", \"bytes\": 50000, \"selected\": 1},\n                 ],\n                 \"links\": [\n                     \"https://ddl.real-debrid.com/ddl/abc123\",\n                     \"https://ddl.real-debrid.com/ddl/def456\",\n                 ],\n             }),\n             # Step 4a: POST /unrestrict/link (first link)\n             _json_response(200, {\n                 \"filename\": \"movie.1080p.mkv\",\n                 \"filesize\": 1500000000,\n                 \"link\": \"https://stream.example.com/movie.1080p.m3u8\",\n             }),\n             # Step 4b: POST /unrestrict/link (second link)\n             _json_response(200, {\n                 \"filename\": \"subtitle.en.srt\",\n                 \"filesize\": 50000,\n                 \"link\": \"https://stream.example.com/subtitle.en.srt\",\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=movie\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         assert len(results) == 1\n         assert results[0].provider == \"real_debrid\"\n         assert results[0].filename == \"movie.1080p.mkv\"\n         assert results[0].stream_url == \"https://stream.example.com/movie.1080p.m3u8\"\n         assert results[0].size_bytes == 1500000000\n         assert results[0].cached is True\n         assert results[0].quality == StreamQuality.FHD\n         assert results[0].mime_type == \"video/x-matroska\"\n \n         await resolver.close()\n \n     async def test_torrent_resolve_empty_links(self):\n         \"\"\"Torrent with no links returns empty list.\"\"\"\n         responses = [\n+            # Pre-flight: GET /torrents/instantAvailability/{hash} — not cached\n+            _json_response(200, {}),\n             _json_response(201, {\n                 \"id\": 12345,\n                 \"uri\": \"https://api.real-debrid.com/rest/1.0/torrents/info/12345\",\n             }),\n             httpx.Response(204, request=httpx.Request(\"POST\", \"https://api.real-debrid.com/rest/1.0/torrents/selectFiles/12345\")),\n             _json_response(200, {\n                 \"id\": 12345,\n                 \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n                 \"filename\": \"empty.torrent\",\n                 \"bytes\": 0,\n                 \"status\": \"waiting_files_selection\",\n                 \"files\": [],\n                 \"links\": [],\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         assert len(results) == 0\n \n         await resolver.close()\n \n     async def test_torrent_resolve_unrestrict_failure_skips_file(self):\n         \"\"\"If a single file's unrestrict endpoint fails, it's skipped not the whole resolve.\"\"\"\n         responses = [\n+            # Pre-flight: GET /torrents/instantAvailability/{hash} — not cached\n+            _json_response(200, {}),\n             _json_response(201, {\n                 \"id\": 12345,\n                 \"uri\": \"https://api.real-debrid.com/rest/1.0/torrents/info/12345\",\n             }),\n             httpx.Response(204, request=httpx.Request(\"POST\", \"https://api.real-debrid.com/rest/1.0/torrents/selectFiles/12345\")),\n             _json_response(200, {\n                 \"id\": 12345,\n                 \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n                 \"filename\": \"movie.mkv\",\n                 \"bytes\": 1000000000,\n                 \"status\": \"downloaded\",\n                 \"files\": [\n                     {\"id\": 0, \"path\": \"movie.mkv\", \"bytes\": 1000000000, \"selected\": 1},\n                     {\"id\": 1, \"path\": \"bad.file\", \"bytes\": 100, \"selected\": 1},\n                 ],\n                 \"links\": [\n                     \"https://ddl.real-debrid.com/ddl/good\",\n                     \"https://ddl.real-debrid.com/ddl/bad\",\n                 ],\n             }),\n             # First link unrestrict succeeds\n             _json_response(200, {\n                 \"filename\": \"movie.mkv\",\n                 \"filesize\": 1000000000,\n                 \"link\": \"https://stream.example.com/movie.mkv\",\n             }),\n             # Second link unrestrict fails\n             _json_response(500, {}),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         # Only the first file should succeed\n         assert len(results) == 1\n         assert results[0].filename == \"movie.mkv\"\n \n         await resolver.close()\n \n \n # ── Request-aware RD contract tests ──────────────────────────────────────\n \n \n class TestRealDebridContract:\n     \"\"\"Verify the exact endpoint paths, methods, auth, and bodies the RD\n     resolver uses against the known Real-Debrid API contract.\"\"\"\n \n     async def test_direct_url_uses_correct_endpoint_and_auth(self):\n         \"\"\"Direct URL resolve must POST /unrestrict/link with Bearer auth and link body.\"\"\"\n         recorded: list[httpx.Request] = []\n \n         def handler(request: httpx.Request) -> httpx.Response:\n             recorded.append(request)\n             return httpx.Response(200, json={\n                 \"filename\": \"movie.mkv\",\n                 \"filesize\": 1000,\n                 \"link\": \"https://cdn.example.com/stream\",\n             })\n \n         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file.mp4\",\n         )\n \n         await resolver.resolve(source, \"sk_test_key_12345\")\n         await resolver.close()\n \n         assert len(recorded) == 1\n         req = recorded[0]\n         assert req.method == \"POST\"\n         assert req.url.path == \"/rest/1.0/unrestrict/link\"\n         assert req.headers.get(\"authorization\") == \"Bearer sk_test_key_12345\"\n         # httpx form-encodes: link=<url_encoded_url>\n         assert b\"link=\" in req.content\n         assert b\"example.com\" in req.content\n \n     async def test_magnet_flow_uses_correct_sequence(self):\n         \"\"\"Magnet resolve must hit addMagnet → selectFiles → info → unrestrict/link.\"\"\"\n         sequence: list[tuple[str, str]] = []  # (method, path)\n \n         def handler(request: httpx.Request) -> httpx.Response:\n             sequence.append((request.method, request.url.path))\n+            if \"instantAvailability\" in request.url.path:\n+                return httpx.Response(200, json={})\n             if \"addMagnet\" in request.url.path:\n                 return httpx.Response(201, json={\"id\": 42, \"uri\": \"/torrents/info/42\"})\n             if \"selectFiles\" in request.url.path:\n                 return httpx.Response(204)\n             if \"torrents/info\" in request.url.path:\n                 return httpx.Response(200, json={\n                     \"id\": 42,\n                     \"filename\": \"movie.mkv\",\n                     \"links\": [\"https://ddl.example.com/link1\"],\n                     \"status\": \"downloaded\",\n                     \"added\": \"2024-01-01T00:00:00+00:00\",\n                 })\n             if \"unrestrict/link\" in request.url.path:\n                 return httpx.Response(200, json={\n                     \"filename\": \"movie.mkv\",\n                     \"filesize\": 1000,\n                     \"link\": \"https://stream.example.com/play.m3u8\",\n                 })\n             return httpx.Response(200, json={})\n \n         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=test\",\n         )\n \n         results = await resolver.resolve(source, \"sk_test_key\")\n         await resolver.close()\n \n-        # Verify exact contract sequence\n-        assert len(sequence) == 4, f\"Expected 4 HTTP calls, got {len(sequence)}\"\n-        assert sequence[0] == (\"POST\", \"/rest/1.0/torrents/addMagnet\"), f\"Step 1: {sequence[0]}\"\n-        assert sequence[1][1].startswith(\"/rest/1.0/torrents/selectFiles/\"), f\"Step 2: {sequence[1]}\"\n-        assert sequence[2] == (\"GET\", \"/rest/1.0/torrents/info/42\"), f\"Step 3: {sequence[2]}\"\n-        assert sequence[3] == (\"POST\", \"/rest/1.0/unrestrict/link\"), f\"Step 4: {sequence[3]}\"\n+        # Verify exact contract sequence (instantAvailability check first, then addMagnet → selectFiles → info → unrestrict)\n+        assert len(sequence) == 5, f\"Expected 5 HTTP calls, got {len(sequence)}\"\n+        assert sequence[0][0] == \"GET\"\n+        assert \"instantAvailability\" in sequence[0][1], f\"Step 0: {sequence[0]}\"\n+        assert sequence[1] == (\"POST\", \"/rest/1.0/torrents/addMagnet\"), f\"Step 1: {sequence[1]}\"\n+        assert sequence[2][1].startswith(\"/rest/1.0/torrents/selectFiles/\"), f\"Step 2: {sequence[2]}\"\n+        assert sequence[3] == (\"GET\", \"/rest/1.0/torrents/info/42\"), f\"Step 3: {sequence[3]}\"\n+        assert sequence[4] == (\"POST\", \"/rest/1.0/unrestrict/link\"), f\"Step 4: {sequence[4]}\"\n \n         # Verify auth on each call\n         assert len(results) == 1\n         assert results[0].stream_url == \"https://stream.example.com/play.m3u8\"\n \n     async def test_magnet_add_contains_magnet_in_body(self):\n         \"\"\"addMagnet must send the magnet URL in the POST body.\"\"\"\n         request_body = None\n \n         def handler(request: httpx.Request) -> httpx.Response:\n             nonlocal request_body\n             if \"addMagnet\" in request.url.path:\n                 request_body = request.content\n                 return httpx.Response(201, json={\"id\": 1, \"uri\": \"/torrents/info/1\"})\n             if \"selectFiles\" in request.url.path:\n                 return httpx.Response(204)\n             if \"torrents/info\" in request.url.path:\n                 return httpx.Response(200, json={\n                     \"id\": 1,\n                     \"filename\": \"movie.mkv\",\n                     \"links\": [],\n                     \"status\": \"downloaded\",\n                 })\n             return httpx.Response(200, json={})\n \n         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=test\",\n         )\n \n         await resolver.resolve(source, \"sk_key\")\n         await resolver.close()\n \n         assert request_body is not None\n         # httpx form-encodes the POST body\n         assert b\"magnet=\" in request_body\n         # The hash should appear in the URL-encoded form body\n         assert b\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\" in request_body\n \n     async def test_info_hash_constructs_magnet_for_add_magnet(self):\n         \"\"\"Info-hash source constructs a magnet URL for addMagnet.\"\"\"\n         request_body = None\n \n         def handler(request: httpx.Request) -> httpx.Response:\n             nonlocal request_body\n             if \"addMagnet\" in request.url.path:\n                 request_body = request.content\n                 return httpx.Response(201, json={\"id\": 1, \"uri\": \"/torrents/info/1\"})\n             if \"selectFiles\" in request.url.path:\n                 return httpx.Response(204)\n             if \"torrents/info\" in request.url.path:\n                 return httpx.Response(200, json={\n                     \"id\": 1,\n                     \"filename\": \"movie.mkv\",\n                     \"links\": [],\n                     \"status\": \"downloaded\",\n                 })\n             return httpx.Response(200, json={})\n \n         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n \n         await resolver.resolve(source, \"sk_key\")\n         await resolver.close()\n \n         assert request_body is not None\n         # httpx form-encodes the POST body: magnet=<url_encoded_magnet>\n         assert b\"magnet=\" in request_body\n         # The constructed magnet URL should appear (URL-encoded in form body)\n         assert b\"magnet\" in request_body and b\"btih\" in request_body\n \n     async def test_unrestrict_contains_link_from_info(self):\n         \"\"\"unrestrict/link must use a link from the info response, not a mangled value.\"\"\"\n         unrestrict_link = None\n \n         def handler(request: httpx.Request) -> httpx.Response:\n             nonlocal unrestrict_link\n             if \"addMagnet\" in request.url.path:\n                 return httpx.Response(201, json={\"id\": 5, \"uri\": \"/torrents/info/5\"})\n             if \"selectFiles\" in request.url.path:\n                 return httpx.Response(204)\n             if \"torrents/info\" in request.url.path:\n                 return httpx.Response(200, json={\n                     \"id\": 5,\n                     \"filename\": \"movie.mkv\",\n                     \"links\": [\"https://ddl.real-debrid.com/ddl/expected-link-value\"],\n                     \"status\": \"downloaded\",\n                 })\n             if \"unrestrict/link\" in request.url.path:\n                 unrestrict_link = request.content\n                 return httpx.Response(200, json={\n                     \"filename\": \"movie.mkv\",\n                     \"filesize\": 1000,\n                     \"link\": \"https://stream.example.com/play.m3u8\",\n                 })\n             return httpx.Response(200, json={})\n \n         client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n \n         results = await resolver.resolve(source, \"sk_key\")\n         await resolver.close()\n \n         assert unrestrict_link is not None\n         # httpx form-encodes the body: link=<url_encoded_url>\n         assert b\"expected-link-value\" in unrestrict_link, f\"Unexpected unrestrict link: {unrestrict_link}\"\n         assert len(results) == 1\n         assert results[0].stream_url == \"https://stream.example.com/play.m3u8\"\n \n     async def test_torrent_url_is_explicitly_unsupported(self):\n         \"\"\"TORRENT_URL source type must raise UnsupportedStreamSourceError for RD.\"\"\"\n         from app.modules.streaming.application.exceptions import UnsupportedStreamSourceError\n \n         client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, json={})))\n         resolver = RealDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.TORRENT_URL,\n             value=\"https://example.com/movie.torrent\",\n         )\n \n         with pytest.raises(UnsupportedStreamSourceError):\n             await resolver.resolve(source, \"sk_key\")\n \n         await resolver.close()\n \n \n # ── Error handling tests ─────────────────────────────────────────────────\n \n \n class TestErrorHandling:\n     async def test_timeout_raises_unavailable(self):\n         \"\"\"httpx.TimeoutException is translated to ProviderStreamUnavailableError.\"\"\"\n         transport = httpx.MockTransport(\n             lambda request: (_ for _ in ()).throw(httpx.TimeoutException(\"timed out\"))\n         )\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file.mp4\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_request_error_raises_unavailable(self):\n         \"\"\"httpx.RequestError is translated to ProviderStreamUnavailableError.\"\"\"\n         transport = httpx.MockTransport(\n             lambda request: (_ for _ in ()).throw(httpx.RequestError(\"connection refused\"))\n         )\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file.mp4\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_401_raises_unavailable(self):\n         \"\"\"401 response is translated to ProviderStreamUnavailableError.\"\"\"\n         responses = [_json_response(401, {\"error\": \"auth required\"})]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file.mp4\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_429_raises_unavailable(self):\n         \"\"\"429 (rate limit) is translated to ProviderStreamUnavailableError.\"\"\"\n         responses = [_json_response(429, {\"error\": \"rate limit\"})]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file.mp4\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_500_raises_unavailable(self):\n         \"\"\"500 response is translated to ProviderStreamUnavailableError.\"\"\"\n         responses = [_json_response(500, {\"error\": \"internal error\"})]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file.mp4\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_api_key_not_leaked_in_error(self):\n         \"\"\"API key must not appear in the exception message.\"\"\"\n         transport = httpx.MockTransport(\n             lambda request: (_ for _ in ()).throw(httpx.TimeoutException(\"timed out\"))\n         )\n         client = httpx.AsyncClient(transport=transport)\n         resolver = RealDebridResolver(http_client=client)\n \n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file.mp4\",\n         )\n \n         api_key = \"sk_live_abc123secret456\"\n         try:\n             await resolver.resolve(source, api_key)\n         except ProviderStreamUnavailableError as exc:\n             assert api_key not in str(exc)\n             assert api_key not in exc.args[0] if exc.args else True\n \n         await resolver.close()\n \n     async def test_unsupported_source_type(self):\n         \"\"\"Magnet without valid btih raises UnsupportedStreamSourceError from resolver.\"\"\"\n         # The domain layer validates magnet format in __post_init__, so we can't\n         # pass an invalid magnet through ResolveSource. Instead, test the\n         # resolver's _extract_hash static method directly.\n         assert RealDebridResolver._extract_hash(\"magnet:?xt=invalid_no_btih\") is None\n         assert RealDebridResolver._extract_hash(\"not-a-magnet\") is None\n         assert RealDebridResolver._extract_hash(\"magnet:?xt=urn:btih:not-a-valid-hash\") is None\n         assert RealDebridResolver._extract_hash(\"magnet:?xt=urn:btih:a1b2/../../bad\") is None\n         assert RealDebridResolver._extract_hash(\"magnet:?xt=urn:btih:a1b2%2Fbad\") is None\n         assert RealDebridResolver._extract_hash(\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\") == \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"\n         assert RealDebridResolver._extract_hash(\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=test\") == \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"\n \n \n # ── Provider attribute test ──────────────────────────────────────────────\n \n \n class TestPremiumizeDirectResolve:\n     async def test_direct_url_unsupported(self):\n         transport = httpx.MockTransport(lambda r: httpx.Response(200, json={}))\n         client = httpx.AsyncClient(transport=transport)\n         resolver = PremiumizeResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/movie.mp4\",\n         )\n         from app.modules.streaming.application.exceptions import UnsupportedStreamSourceError\n         with pytest.raises(UnsupportedStreamSourceError):\n             await resolver.resolve(source, \"test_api_key\")\n         await resolver.close()\n \n \n class TestPremiumizeTorrentResolve:\n     async def test_magnet_success(self):\n         responses = [\n             _json_response(200, {\"status\": \"success\", \"id\": \"pm_12345\"}),\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"content\": [\n                     {\"id\": \"f1\", \"name\": \"movie.1080p.mkv\", \"size\": 1500000000,\n                      \"stream_url\": \"https://stream.premiumize.me/movie.m3u8\"},\n                 ],\n             }),\n         ]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = PremiumizeResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=movie\",\n         )\n         results = await resolver.resolve(source, \"test_api_key\")\n         assert len(results) == 1\n         assert results[0].provider == \"premiumize\"\n         await resolver.close()\n \n     async def test_create_failure_raises(self):\n         responses = [\n             _json_response(200, {\"status\": \"error\", \"message\": \"Invalid magnet\"}),\n         ]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = PremiumizeResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=movie\",\n         )\n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n         await resolver.close()\n \n     async def test_empty_content_returns_empty(self):\n         responses = [\n             _json_response(200, {\"status\": \"success\", \"id\": \"pm_12345\"}),\n             _json_response(200, {\"status\": \"success\", \"content\": []}),\n         ]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = PremiumizeResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n         results = await resolver.resolve(source, \"test_api_key\")\n         assert len(results) == 0\n         await resolver.close()\n \n \n class TestProviderAttribute:\n     def test_real_debrid_name(self):\n         assert RealDebridResolver.provider == \"real_debrid\"\n \n     def test_all_debrid_name(self):\n         assert AllDebridResolver.provider == \"all_debrid\"\n \n     def test_premiumize_name(self):\n         assert PremiumizeResolver.provider == \"premiumize\"\n \n     def test_torbox_name(self):\n         assert TorBoxResolver.provider == \"torbox\"\n \n \n # ── TorBox Resolver Tests ─────────────────────────────────────────────\n \n \n class TestTorBoxTorrentResolve:\n     async def test_mylist_is_filtered_to_created_torrent(self):\n         responses = [\n             _json_response(200, {\"status\": \"success\", \"data\": {\"torrent_id\": \"tb_expected\"}}),\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": [\n                     {\n                         \"id\": \"tb_unrelated\",\n                         \"hash\": \"ffffffffffffffffffffffffffffffffffffffff\",\n                         \"files\": [\n                             {\"url\": \"https://stream.torbox.app/wrong.m3u8\", \"name\": \"wrong.mkv\", \"size\": 1},\n                         ],\n                     },\n                     {\n                         \"id\": \"tb_expected\",\n                         \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n                         \"files\": [\n                             {\"url\": \"https://stream.torbox.app/movie.m3u8\", \"name\": \"movie.mkv\", \"size\": 2},\n                         ],\n                     },\n                 ],\n             }),\n         ]\n         client = httpx.AsyncClient(transport=_make_mock_transport(responses))\n         resolver = TorBoxResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         assert len(results) == 1\n         assert results[0].filename == \"movie.mkv\"\n         assert results[0].stream_url == \"https://stream.torbox.app/movie.m3u8\"\n         await resolver.close()\n \n     async def test_magnet_success(self):\n         responses = [\n             # POST /torrents/createtorrent\n             _json_response(200, {\"status\": \"success\", \"data\": {\"torrent_id\": \"tb_12345\"}}),\n             # GET /torrents/mylist\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": [\n                     {\n                         \"id\": \"tb_12345\",\n                         \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n                         \"files\": [\n                             {\"url\": \"https://stream.torbox.app/movie.m3u8\", \"name\": \"movie.1080p.mkv\", \"size\": 1500000000},\n                         ],\n                     },\n                 ],\n             }),\n         ]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = TorBoxResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=movie\",\n         )\n         results = await resolver.resolve(source, \"test_api_key\")\n         assert len(results) == 1\n         assert results[0].provider == \"torbox\"\n         assert results[0].filename == \"movie.1080p.mkv\"\n         assert results[0].stream_url == \"https://stream.torbox.app/movie.m3u8\"\n         await resolver.close()\n \n     async def test_create_failure_raises(self):\n         responses = [\n             _json_response(200, {\"status\": \"error\", \"message\": \"Invalid magnet\"}),\n         ]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = TorBoxResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=movie\",\n         )\n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n         await resolver.close()\n \n     async def test_empty_files_returns_empty(self):\n         responses = [\n             _json_response(200, {\"status\": \"success\", \"data\": {\"torrent_id\": \"tb_12345\"}}),\n             _json_response(200, {\"status\": \"success\", \"data\": [{\"id\": \"tb_12345\", \"files\": []}]}),\n         ]\n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = TorBoxResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n         results = await resolver.resolve(source, \"test_api_key\")\n         assert len(results) == 0\n         await resolver.close()\n \n     async def test_direct_url_unsupported(self):\n         transport = httpx.MockTransport(lambda r: httpx.Response(200, json={}))\n         client = httpx.AsyncClient(transport=transport)\n         resolver = TorBoxResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/movie.mp4\",\n         )\n         from app.modules.streaming.application.exceptions import UnsupportedStreamSourceError\n         with pytest.raises(UnsupportedStreamSourceError):\n             await resolver.resolve(source, \"test_api_key\")\n         await resolver.close()\n \n \n # ── AllDebrid Resolver Tests ───────────────────────────────────────────\n \n \n class TestAllDebridDirectResolve:\n     \"\"\"Tests for AllDebrid direct URL resolution.\"\"\"\n \n     async def test_direct_url_success(self):\n         \"\"\"Direct URL is unrestricted and returns a stream result.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": {\n                     \"filename\": \"movie.1080p.mkv\",\n                     \"size\": 1234567890,\n                     \"link\": \"https://cdn.alldebrid.com/stream/movie.mkv\",\n                 },\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = AllDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/movie.mkv\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         assert len(results) == 1\n         assert results[0].provider == \"all_debrid\"\n         assert results[0].filename == \"movie.1080p.mkv\"\n         assert results[0].stream_url == \"https://cdn.alldebrid.com/stream/movie.mkv\"\n         assert results[0].size_bytes == 1234567890\n         assert results[0].mime_type == \"video/x-matroska\"\n         assert results[0].quality == StreamQuality.FHD\n \n         await resolver.close()\n \n     async def test_direct_url_api_error_raised(self):\n         \"\"\"AllDebrid error status is translated to ProviderStreamUnavailableError.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"status\": \"error\",\n                 \"error\": {\"code\": \"AUTH_BAD_APIKEY\", \"message\": \"Bad API key\"},\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = AllDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/movie.mp4\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_direct_url_empty_link_raises(self):\n         \"\"\"AllDebrid response without link raises unavailable.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": {\n                     \"filename\": \"movie.mp4\",\n                     \"size\": 100,\n                     \"link\": \"\",\n                 },\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = AllDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.DIRECT_URL,\n             value=\"https://example.com/file\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n \n class TestAllDebridMagnetResolve:\n     \"\"\"Tests for AllDebrid magnet resolution.\"\"\"\n \n     async def test_magnet_success(self):\n         \"\"\"Magnet link is uploaded and links resolved.\"\"\"\n         responses = [\n             # POST /magnet/upload\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": {\n                     \"magnets\": [\n                         {\"id\": 12345, \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"},\n                     ],\n                 },\n             }),\n             # GET /magnet/status\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": {\n                     \"magnets\": [\n                         {\n                             \"id\": 12345,\n                             \"filename\": \"movie.1080p.mkv\",\n                             \"size\": 2000000000,\n                             \"links\": [\n                                 {\n                                     \"link\": \"https://stream.alldebrid.com/movie.m3u8\",\n                                     \"filename\": \"movie.1080p.mkv\",\n                                     \"size\": 1500000000,\n                                 },\n                             ],\n                         },\n                     ],\n                 },\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = AllDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=movie\",\n         )\n \n         results = await resolver.resolve(source, \"test_api_key\")\n \n         assert len(results) == 1\n         assert results[0].provider == \"all_debrid\"\n         assert results[0].filename == \"movie.1080p.mkv\"\n         assert results[0].stream_url == \"https://stream.alldebrid.com/movie.m3u8\"\n         assert results[0].cached is False\n \n         await resolver.close()\n \n     async def test_magnet_empty_links_raises(self):\n         \"\"\"Magnet with no resolved links raises unavailable.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": {\"magnets\": [{\"id\": 12345}]},\n             }),\n             _json_response(200, {\n                 \"status\": \"success\",\n                 \"data\": {\n                     \"magnets\": [\n                         {\"id\": 12345, \"links\": []},\n                     ],\n                 },\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = AllDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.INFO_HASH,\n             value=\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n \n     async def test_magnet_api_error_raised(self):\n         \"\"\"AllDebrid magnet API error is translated to unavailable.\"\"\"\n         responses = [\n             _json_response(200, {\n                 \"status\": \"error\",\n                 \"error\": {\"code\": \"NO_SERVER\", \"message\": \"No server available\"},\n             }),\n         ]\n \n         transport = _make_mock_transport(responses)\n         client = httpx.AsyncClient(transport=transport)\n         resolver = AllDebridResolver(http_client=client)\n         source = ResolveSource(\n             source_type=ResolveSourceType.MAGNET,\n             value=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=movie\",\n         )\n \n         with pytest.raises(ProviderStreamUnavailableError):\n             await resolver.resolve(source, \"test_api_key\")\n \n         await resolver.close()\n",
    "additions": 16,
    "deletions": 6,
    "status": "modified"
  }
]