Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions comet/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from starlette.requests import Request

from comet.api.endpoints import (admin, base, chilllink, cometnet, cometnet_ui,
config, debrid_sync, kodi, manifest, playback)
config, custom_catalog, debrid_sync, kodi, manifest, playback)
from comet.api.endpoints import stream as streams_router
from comet.background_scraper.worker import background_scraper
from comet.cometnet.manager import init_cometnet_service
Expand Down Expand Up @@ -80,7 +80,8 @@ async def lifespan(app: FastAPI):
if settings.BACKGROUND_SCRAPER_ENABLED:
background_scraper.clear_finished_task()
if not background_scraper.task:
background_scraper.task = asyncio.create_task(background_scraper.start())
background_scraper.task = asyncio.create_task(
background_scraper.start())

# Start DMM Ingester if enabled
dmm_ingester_task = None
Expand Down Expand Up @@ -108,8 +109,10 @@ async def lifespan(app: FastAPI):

# Set callback to save torrents received from the network
cometnet_service.set_save_torrent_callback(save_torrent_from_network)
cometnet_service.set_check_torrent_exists_callback(check_torrent_exists)
cometnet_service.set_check_torrents_exist_callback(check_torrents_exist)
cometnet_service.set_check_torrent_exists_callback(
check_torrent_exists)
cometnet_service.set_check_torrents_exist_callback(
check_torrents_exist)
await cometnet_service.start()

# Start indexer manager
Expand Down Expand Up @@ -231,6 +234,7 @@ async def lifespan(app: FastAPI):
debrid_sync.router,
streams_router.streams,
chilllink.router,
custom_catalog.router,
)

for stremio_router in stremio_routers:
Expand Down
228 changes: 228 additions & 0 deletions comet/api/endpoints/custom_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"""
Custom catalog proxy endpoints.

Handles:
- GET /{b64config}/catalog/{type}/{id}.json
- GET /{b64config}/catalog/{type}/{id}/{extra:path}.json

Catalog IDs with the pattern `cstm{idx}_{prefix}_{type}` are proxied
to the user-configured custom catalog addon URL.

Also exposes a helper `resolve_custom_prefix_to_imdb` used by stream.py
to convert custom-prefix IDs (e.g. csfd12345) into IMDB IDs.
"""

import asyncio
from typing import Optional

import aiohttp
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from loguru import logger

from comet.core.config_validation import config_check
from comet.utils.http_client import http_client_manager

router = APIRouter()


# ---------------------------------------------------------------------------
# Internal HTTP helper
# ---------------------------------------------------------------------------

async def _fetch_json(url: str, timeout: int = 15) -> Optional[dict]:
try:
session = await http_client_manager.get_session()
async with session.get(
url,
timeout=aiohttp.ClientTimeout(total=timeout),
headers={"Accept": "application/json"},
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.info(f"Custom catalog: fetch success for {url}")
return data
logger.warning(f"Custom catalog: HTTP {resp.status} for {url}")
try:
text = await resp.text()
logger.warning(f"Custom catalog err body: {text}")
except Exception as e:
logger.debug(
f"Custom catalog: failed reading response body: {e}", exc_info=True)
except asyncio.TimeoutError:
logger.warning(f"Custom catalog: timeout fetching {url}")
except Exception as e:
logger.warning(f"Custom catalog: error fetching {url}: {e}")
return None


# ---------------------------------------------------------------------------
# Helper: resolve custom-prefix ID → IMDB ID
# ---------------------------------------------------------------------------

async def resolve_custom_prefix_to_imdb(
media_type: str,
media_id: str,
custom_catalogs: list,
timeout: int = 15,
) -> tuple[Optional[str], Optional[dict]]:
"""
For a media_id whose prefix matches one of the user's customCatalogs,
call /meta/{type}/{media_id}.json on the corresponding addon URL and
attempt to extract an IMDB ID from the response.

Returns ``(imdb_id, meta_dict)``. If IMDB ID is not found, imdb_id is None,
but meta_dict might still contain title/year for fallback scraping.
"""
matched_url: Optional[str] = None
for entry in custom_catalogs or []:
prefix = (entry.get("prefix") or "").strip()
url = (entry.get("url") or "").strip().rstrip("/")
if prefix and url and media_id.startswith(prefix):
matched_url = url
break

if not matched_url:
logger.warning(
f"Custom catalog plugin logic: NO MATCHED URL for {media_id}")
return None, None

# Stremio uses IDs like prefix123:1:2 for series streams, but custom catalogs
# usually only respond to the base ID (e.g. prefix123) for meta endpoints.
base_id = media_id.split(":")[0]

meta_url = f"{matched_url}/meta/{media_type}/{base_id}.json"
logger.info(f"Custom catalog: requesting IMDB resolution from {meta_url}")
data = await _fetch_json(meta_url, timeout)
if not data:
logger.warning(
f"Custom catalog: fetch returned empty/none for {meta_url}")
return None, None

meta = data.get("meta") or {}
logger.info(f"Custom catalog: received meta keys = {list(meta.keys())}")

# Try common locations for IMDB ID - adjust to actual API response structure
for candidate in [
meta.get("imdbId"),
meta.get("imdb"),
meta.get("tt"),
(meta.get("externalIds") or {}).get("imdb"),
(meta.get("externalIds") or {}).get("imdbId"),
(meta.get("filmOverviewOut") or {}).get("imdbId"),
((meta.get("filmOverviewOut") or {}).get("externalIds") or {}).get("imdb"),
]:
if candidate and str(candidate).startswith("tt"):
return str(candidate), meta

return None, meta


# ---------------------------------------------------------------------------
# Catalog proxy endpoints
# ---------------------------------------------------------------------------

def _parse_catalog_id(catalog_id: str) -> Optional[tuple]:
"""
Parse a catalog ID of the form ``cstm{idx}_{prefix}_{type}``.
Returns ``(idx, prefix, catalog_type)`` or ``None``.
"""
if not catalog_id.startswith("cstm"):
return None
rest = catalog_id[4:] # strip "cstm"
try:
underscore_pos = rest.index("_")
idx = int(rest[:underscore_pos])
remainder = rest[underscore_pos + 1:]
# remainder is "{prefix}_{type}" - split at the *last* underscore
# because prefix itself may not contain underscores and type is
# always the rightmost segment.
last_underscore = remainder.rfind("_")
if last_underscore < 0:
return None
prefix = remainder[:last_underscore]
cat_type = remainder[last_underscore + 1:]
if not prefix or not cat_type:
return None
return idx, prefix, cat_type
except (ValueError, IndexError):
return None


async def _handle_catalog(
b64config: str,
catalog_type: str,
catalog_id: str,
extra: str,
) -> JSONResponse:
parsed = _parse_catalog_id(catalog_id)
if not parsed:
return JSONResponse({"metas": []})

idx, prefix, _declared_type = parsed

config = config_check(b64config, strict_b64config=False)
if not config:
return JSONResponse({"metas": []})

custom_catalogs = config.get("customCatalogs") or []
if idx >= len(custom_catalogs):
logger.warning(
f"Custom catalog: index {idx} out of range for user config")
return JSONResponse({"metas": []})

entry = custom_catalogs[idx]
base_url = (entry.get("url") or "").strip().rstrip("/")
entry_prefix = (entry.get("prefix") or "").strip()

if not base_url or not entry_prefix:
return JSONResponse({"metas": []})

# Safety: verify prefix still matches what is stored in user config
if entry_prefix != prefix:
logger.warning(
f"Custom catalog: prefix mismatch: config has {entry_prefix!r}, "
f"catalog_id implies {prefix!r}"
)
return JSONResponse({"metas": []})

# The original catalog ID on the remote addon is constructed from the prefix
# and the requested catalog type. The manifest endpoint registers catalogs
# using the pattern ``cstm{idx}_{prefix}_{type}`` which maps to
# ``{prefix}_{catalog_type}`` on the upstream addon.
original_catalog_id = f"{prefix}_{catalog_type}"
if extra:
proxy_url = f"{base_url}/catalog/{catalog_type}/{original_catalog_id}/{extra}.json"
else:
proxy_url = f"{base_url}/catalog/{catalog_type}/{original_catalog_id}.json"

data = await _fetch_json(proxy_url)
if data and isinstance(data.get("metas"), list):
return JSONResponse(
content=data,
headers={"Access-Control-Allow-Origin": "*"},
)
return JSONResponse(
content={"metas": []},
headers={"Access-Control-Allow-Origin": "*"},
)


@router.get(
"/{b64config}/catalog/{catalog_type}/{catalog_id}.json",
tags=["Stremio"],
summary="Custom Catalog Proxy",
description="Proxies catalog requests to user-configured external Stremio catalog addons.",
)
async def catalog(b64config: str, catalog_type: str, catalog_id: str):
return await _handle_catalog(b64config, catalog_type, catalog_id, extra="")


@router.get(
"/{b64config}/catalog/{catalog_type}/{catalog_id}/{extra:path}.json",
tags=["Stremio"],
summary="Custom Catalog Proxy (with extra)",
description="Proxies catalog requests with extra params (search, skip, genre…) to external catalog addons.",
)
async def catalog_with_extra(b64config: str, catalog_type: str, catalog_id: str, extra: str):
return await _handle_catalog(b64config, catalog_type, catalog_id, extra=extra)
72 changes: 71 additions & 1 deletion comet/api/endpoints/manifest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Request

from comet.core.config_validation import config_check
from comet.core.models import settings
from comet.core.models import BUILTIN_PREFIXES, settings
from comet.debrid.manager import build_addon_name
from comet.utils.cache import (CachedJSONResponse, CachePolicies,
check_etag_match, generate_etag,
Expand All @@ -10,6 +10,48 @@
router = APIRouter()


def _build_custom_catalog_manifest(custom_catalogs: list) -> tuple[list, list]:
"""
Given a user's customCatalogs config (list of {url, prefix} dicts),
return (stremio_catalogs, extra_id_prefixes).

stremio_catalogs - catalog entries to include in the manifest
extra_id_prefixes - additional idPrefixes to advertise so Stremio sends
stream requests for IDs with those prefixes to Comet
"""
stremio_catalogs = []
extra_prefixes = []

seen_prefixes = set()
for idx, entry in enumerate(custom_catalogs or []):
url = (entry.get("url") or "").strip().rstrip("/")
prefix = (entry.get("prefix") or "").strip()
if not url or not prefix:
continue
if prefix in BUILTIN_PREFIXES:
continue # never override built-ins

# One search-style catalog per custom addon (minimal; Stremio will
# discover via the addon's own manifest, but we still expose it so
# the user can search from the Comet manifest).
stremio_catalogs.append({
"type": "movie",
"id": f"cstm{idx}_{prefix}_movie",
"name": f"Custom ({prefix})",
})
stremio_catalogs.append({
"type": "series",
"id": f"cstm{idx}_{prefix}_series",
"name": f"Custom ({prefix})",
})

if prefix not in seen_prefixes:
extra_prefixes.append(prefix)
seen_prefixes.add(prefix)

return stremio_catalogs, extra_prefixes


@router.get(
"/manifest.json",
tags=["Stremio"],
Expand Down Expand Up @@ -51,6 +93,34 @@ async def manifest(request: Request, b64config: str = None):

base_manifest["name"] = build_addon_name(settings.ADDON_NAME, config)

# Inject custom catalog entries and extra idPrefixes from user config
custom_catalogs_cfg = config.get("customCatalogs") or []
if custom_catalogs_cfg:
stremio_catalogs, extra_prefixes = _build_custom_catalog_manifest(
custom_catalogs_cfg)
if stremio_catalogs:
base_manifest["catalogs"] = stremio_catalogs
# Add "catalog" to resources if not already present
resource_names = [
r["name"] if isinstance(r, dict) else r
for r in base_manifest["resources"]
]
if "catalog" not in resource_names:
base_manifest["resources"].append("catalog")

if extra_prefixes:
# Extend the stream resource's idPrefixes
stream_resource = next(
(r for r in base_manifest["resources"] if isinstance(
r, dict) and r.get("name") == "stream"),
None,
)
if stream_resource:
existing = stream_resource.get("idPrefixes", [])
stream_resource["idPrefixes"] = existing + [
p for p in extra_prefixes if p not in existing
]

if settings.HTTP_CACHE_ENABLED:
etag = generate_etag(base_manifest)
if check_etag_match(request, etag):
Expand Down
Loading