Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ TORRENT_DISABLED_STREAM_NAME=[INFO] Comet # Stremio stream name shown when torre
TORRENT_DISABLED_STREAM_DESCRIPTION=Direct torrent playback is disabled on this server. Please configure a debrid provider. # Description shown to users in Stremio
TORRENT_DISABLED_STREAM_URL=https://comet.fast # Optional URL included in the placeholder stream response

# ============================== #
# Stream Result Limits #
# ============================== #
MAX_RESULTS_PER_RESOLUTION_CAP=0 # If >0, overrides client maxResultsPerResolution with this cap
MAX_STREAM_RESULTS_TOTAL=0 # If >0, hard limit on total streams returned per request

# ============================== #
# Content Filtering #
# ============================== #
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
* add `GUNICORN_PRELOAD_APP` setting to control whether workers inherit a preloaded app or initialize independently
* add `DATABASE_STARTUP_CLEANUP_INTERVAL` to throttle heavy startup cleanup sweeps across workers
* add `DISABLE_TORRENT_STREAMS` toggle with customizable placeholder stream metadata
* expose Prometheus `/metrics` endpoint tracking stream requests per debrid service
* add server-side stream result caps via `MAX_RESULTS_PER_RESOLUTION_CAP` and `MAX_STREAM_RESULTS_TOTAL`

## [2.31.0](https://github.com/g0ldyy/comet/compare/v2.30.0...v2.31.0) (2025-12-08)

Expand Down
8 changes: 8 additions & 0 deletions PR_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Branch Summary

- Added replica-aware database routing (`comet/core/db_router.py`, `comet/core/database.py`, `comet/core/models.py`), letting PostgreSQL deployments list `DATABASE_READ_REPLICA_URLS` so reads can go to replicas while writes remain on the primary, plus a force-primary escape hatch inside maintenance queries.
- Reduced startup thrash by gating the heavy cleanup sweep behind `DATABASE_STARTUP_CLEANUP_INTERVAL` and persisting anime mapping data in `anime_mapping_cache` tables so most boots load instantly from the DB instead of redownloading (`comet/core/database.py`, `comet/services/anime.py`, env knobs `ANIME_MAPPING_SOURCE`/`ANIME_MAPPING_REFRESH_INTERVAL`).
- Hardened torrent ingestion and ranking: `TorrentManager` now funnels inserts through an `INSERT ... ON CONFLICT DO UPDATE` helper to quiet duplicate-key races, and ranking/logging paths gained small cleanups (`comet/services/torrent_manager.py`, `comet/services/lock.py`, logging tweaks in `comet/core/log_levels.py`).
- Added an env-toggle to skip Gunicorn preload (`GUNICORN_PRELOAD_APP`) so large deployments can trade startup cost vs. schema-read requirements (`comet/main.py`, `.env-sample`).
- Introduced `DISABLE_TORRENT_STREAMS` plus customizable placeholder metadata so operators can block magnet-only usage and show a friendly Stremio message instead (`comet/api/endpoints/stream.py`, `comet/core/models.py`, `.env-sample`).
- Updated documentation/config samples and changelog to capture the new settings and behavior (`.env-sample`, `CHANGELOG.md`).
Comment on lines +1 to +8
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

PR summary misses the PR’s headline features (metrics + server-side result caps + fail-early invalid debrid)

Given the PR title/objectives, add explicit bullets for:

  • Prometheus /metrics endpoint + the new counters (and label dimensions)
  • The new “cap results” settings (per-resolution vs total)
  • The “fail early for invalid debrid accounts” behavior
🤖 Prompt for AI Agents
In PR_SUMMARY.md around lines 1 to 8, the summary omits key headline features:
add explicit bullets describing the Prometheus /metrics endpoint and new
counters with their label dimensions, the new result-cap settings
(per-resolution and total caps) and their effect, and the fail-early behavior
for invalid debrid accounts; update the bulleted list to include three concise
bullets covering (1) Prometheus /metrics endpoint plus which counters and labels
were added, (2) the configurable result caps with distinction between
per-resolution vs total caps, and (3) the fail-early invalid debrid account
behavior and where it is enforced.

2 changes: 2 additions & 0 deletions comet/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from starlette.requests import Request

from comet.api.endpoints import admin, base, config, manifest, playback
from comet.api.endpoints import metrics as metrics_router
from comet.api.endpoints import stream as streams_router
from comet.background_scraper.worker import background_scraper
from comet.core.database import (cleanup_expired_locks,
Expand Down Expand Up @@ -122,4 +123,5 @@ async def lifespan(app: FastAPI):
app.include_router(manifest.router)
app.include_router(admin.router)
app.include_router(playback.router)
app.include_router(metrics_router.router)
app.include_router(streams_router.streams)
1 change: 1 addition & 0 deletions comet/api/endpoints/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ async def configure(request: Request):
else "",
"webConfig": web_config,
"proxyDebridStream": settings.PROXY_DEBRID_STREAM,
"disableTorrentStreams": settings.DISABLE_TORRENT_STREAMS,
},
)
10 changes: 10 additions & 0 deletions comet/api/endpoints/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import APIRouter

from comet.core.metrics import prom_response

router = APIRouter()


@router.get("/metrics")
async def metrics_endpoint():
return prom_response()
Comment on lines +1 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "PUBLIC_METRICS_API" --type=py -C3

Repository: g0ldyy/comet

Length of output: 1522


LGTM! Metrics endpoint should respect PUBLIC_METRICS_API setting.

The implementation is clean and minimal. However, the metrics endpoint doesn't check settings.PUBLIC_METRICS_API like the admin metrics endpoint does in /admin/api/metrics. For consistency and security, add a check to require admin authentication when PUBLIC_METRICS_API=False:

from fastapi import APIRouter, Cookie

from comet.core.metrics import prom_response
from comet.api.endpoints.admin import require_admin_auth

router = APIRouter()

@router.get("/metrics")
async def metrics_endpoint(admin_session: str = Cookie(None)):
    if not settings.PUBLIC_METRICS_API:
        await require_admin_auth(admin_session)
    return prom_response()
🤖 Prompt for AI Agents
In comet/api/endpoints/metrics.py lines 1-10, the metrics endpoint currently
always returns prom_response() and does not respect settings.PUBLIC_METRICS_API;
update the function to import settings, Cookie, and require_admin_auth, add an
admin_session: str = Cookie(None) parameter, and if settings.PUBLIC_METRICS_API
is False call await require_admin_auth(admin_session) before returning
prom_response() so the endpoint requires admin authentication when public
metrics are disabled.

46 changes: 46 additions & 0 deletions comet/api/endpoints/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from comet.core.config_validation import config_check
from comet.core.logger import logger
from comet.core.metrics import (record_non_debrid_stream_request,
record_stream_request)
from comet.core.models import database, settings, trackers
from comet.debrid.manager import get_debrid_extension
from comet.metadata.manager import MetadataScraper
Expand Down Expand Up @@ -140,6 +142,21 @@ async def stream(
]
}

per_resolution_cap = settings.MAX_RESULTS_PER_RESOLUTION_CAP or 0
if per_resolution_cap > 0:
client_value = config.get("maxResultsPerResolution") or 0
if client_value == 0 or client_value > per_resolution_cap:
logger.log(
"SCRAPER",
f"Clamping maxResultsPerResolution from {client_value} to {per_resolution_cap}",
)
config["maxResultsPerResolution"] = per_resolution_cap

if config.get("debridService") == "torrent":
record_non_debrid_stream_request()

record_stream_request(config.get("debridService"))

if settings.DISABLE_TORRENT_STREAMS and config["debridService"] == "torrent":
placeholder_stream = {
"name": settings.TORRENT_DISABLED_STREAM_NAME or "[INFO] Comet",
Expand Down Expand Up @@ -360,6 +377,25 @@ async def stream(
await scrape_lock.release()
lock_acquired = False

if debrid_service != "torrent":
is_valid_key = await debrid_service_instance.validate_credentials(
session, media_id, media_only_id
)
if not is_valid_key:
logger.log(
"SCRAPER",
f"❌ Invalid API key for {debrid_service}; refusing to serve streams",
)
return {
"streams": [
{
"name": "[⚠️] Comet",
"description": f"Invalid or unauthorized API key for {debrid_service}. Please update your configuration.",
"url": "https://comet.fast",
}
]
}

await debrid_service_instance.check_existing_availability(
torrent_manager.torrents, season, episode
)
Expand Down Expand Up @@ -432,7 +468,15 @@ async def stream(
result_episode = episode if episode is not None else "n"

torrents = torrent_manager.torrents
max_stream_results = settings.MAX_STREAM_RESULTS_TOTAL or 0
streams_emitted = 0
for info_hash in torrent_manager.ranked_torrents:
if max_stream_results > 0 and streams_emitted >= max_stream_results:
logger.log(
"SCRAPER",
f"🔪 Truncated streams at {max_stream_results} entries (caps applied)",
)
break
torrent = torrents[info_hash]
rtn_data = torrent["parsed"]

Expand Down Expand Up @@ -480,4 +524,6 @@ async def stream(
else:
non_cached_results.append(the_stream)

streams_emitted += 1

return {"streams": cached_results + non_cached_results}
1 change: 0 additions & 1 deletion comet/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,6 @@ async def _run_startup_cleanup():
ON CONFLICT (id) DO UPDATE SET last_startup_cleanup = :timestamp
""",
{"timestamp": current_time},
force_primary=True,
)
finally:
if lock_acquired:
Expand Down
36 changes: 36 additions & 0 deletions comet/core/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from fastapi import Response
from prometheus_client import (CONTENT_TYPE_LATEST, CollectorRegistry, Counter,
generate_latest)

# Dedicated registry so we only expose Comet-specific metrics
_registry = CollectorRegistry()

_stream_requests_total = Counter(
"comet_stream_requests_total",
"Total number of stream requests grouped by debrid service",
["debrid_service"],
registry=_registry,
)

_non_debrid_stream_requests_total = Counter(
"comet_stream_requests_non_debrid_total",
"Total number of stream requests that do not require a user API key",
registry=_registry,
)
Comment on lines +1 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prometheus label cardinality: ensure debrid_service is bounded/allowlisted

If debrid_service can vary widely (or be user-influenced), comet_stream_requests_total{debrid_service=...} can create high-cardinality time series and stress Prometheus/Grafana. Consider mapping to a small allowlist (and collapsing everything else to "unknown"/"other").

🤖 Prompt for AI Agents
In comet/core/metrics.py lines 1-19, the debrid_service label on
comet_stream_requests_total can create high cardinality; validate and normalize
the label to a bounded allowlist before using it. Add a small constant
ALLOWED_DEBRID_SERVICES set and a helper (e.g., normalize_debrid_service(name))
that returns the original name if in the set or "other"/"unknown" otherwise, and
use that normalized value whenever incrementing
_stream_requests_total.labels(...). Ensure the allowlist is documented and
updated centrally when supported services change.



def record_stream_request(debrid_service: str | None):
"""Increment the stream request counter for the provided service."""
label = (debrid_service or "unknown").lower()
_stream_requests_total.labels(debrid_service=label).inc()


def record_non_debrid_stream_request():
"""Increment the counter tracking torrent (non-API) stream requests."""
_non_debrid_stream_requests_total.inc()


def prom_response() -> Response:
"""Return a Response containing the current Prometheus metrics payload."""
payload = generate_latest(_registry)
return Response(content=payload, media_type=CONTENT_TYPE_LATEST)
16 changes: 16 additions & 0 deletions comet/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ class AppSettings(BaseSettings):
"Direct torrent playback is disabled on this server."
)
TORRENT_DISABLED_STREAM_URL: Optional[str] = "https://comet.fast"
MAX_RESULTS_PER_RESOLUTION_CAP: Optional[int] = 0
MAX_STREAM_RESULTS_TOTAL: Optional[int] = 0
REMOVE_ADULT_CONTENT: Optional[bool] = False
BACKGROUND_SCRAPER_ENABLED: Optional[bool] = False
BACKGROUND_SCRAPER_CONCURRENT_WORKERS: Optional[int] = 1
Expand Down Expand Up @@ -182,6 +184,20 @@ def normalize_urls(cls, v):
return [url.rstrip("/") for url in v]
return v

@field_validator(
"MAX_RESULTS_PER_RESOLUTION_CAP",
"MAX_STREAM_RESULTS_TOTAL",
mode="before",
)
def non_negative_int(cls, v):
if v is None:
return 0
try:
value = int(v)
except (TypeError, ValueError):
return 0
return value if value >= 0 else 0

def is_scraper_enabled(self, scraper_setting: Union[bool, str], context: str):
if isinstance(scraper_setting, bool):
return scraper_setting
Expand Down
10 changes: 8 additions & 2 deletions comet/metadata/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,15 @@ async def cache_metadata(self, media_id: str, metadata: dict, aliases: dict):
)

def normalize_metadata(self, metadata: dict, season: int, episode: int):
title, year, year_end = metadata
if not metadata:
return None

try:
title, year, year_end = metadata
except Exception:
return None

if title is None: # metadata retrieving failed
if title is None:
return None

return {
Expand Down
27 changes: 26 additions & 1 deletion comet/services/debrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import orjson
from RTN import ParsedData

from comet.debrid.manager import retrieve_debrid_availability
from comet.core.logger import logger
from comet.debrid.manager import get_debrid, retrieve_debrid_availability
from comet.services.debrid_cache import (cache_availability,
get_cached_availability)

Expand All @@ -14,6 +15,30 @@ def __init__(self, debrid_service: str, debrid_api_key: str, ip: str):
self.debrid_api_key = debrid_api_key
self.ip = ip

async def validate_credentials(
self, session, media_id: str, media_only_id: str
) -> bool:
if self.debrid_service == "torrent":
return True

try:
client = get_debrid(
session,
media_id,
media_only_id,
self.debrid_service,
self.debrid_api_key,
self.ip,
)
if client is None:
return False
return await client.check_premium()
except Exception as e:
logger.warning(
f"Failed to validate credentials for {self.debrid_service}: {e}"
)
return False

async def get_and_cache_availability(
self,
session,
Expand Down
26 changes: 19 additions & 7 deletions comet/services/torrent_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,11 +465,23 @@ async def _check_batch_size(self):
timestamp = EXCLUDED.timestamp
"""

POSTGRES_CONFLICT_TARGETS = {
"series": "(media_id, info_hash, season, episode) WHERE season IS NOT NULL AND episode IS NOT NULL",
"season_only": "(media_id, info_hash, season) WHERE season IS NOT NULL AND episode IS NULL",
"episode_only": "(media_id, info_hash, episode) WHERE season IS NULL AND episode IS NOT NULL",
"none": "(media_id, info_hash) WHERE season IS NULL AND episode IS NULL",
POSTGRES_CONFLICT_MAP = {
"series": {
"target": "(media_id, info_hash, season, episode)",
"predicate": "WHERE season IS NOT NULL AND episode IS NOT NULL",
},
"season_only": {
"target": "(media_id, info_hash, season)",
"predicate": "WHERE season IS NOT NULL AND episode IS NULL",
},
"episode_only": {
"target": "(media_id, info_hash, episode)",
"predicate": "WHERE season IS NULL AND episode IS NOT NULL",
},
"none": {
"target": "(media_id, info_hash)",
"predicate": "WHERE season IS NULL AND episode IS NULL",
},
}

_POSTGRES_UPSERT_CACHE: dict[str, str] = {}
Expand All @@ -490,11 +502,11 @@ def _get_torrent_upsert_query(conflict_key: str) -> str:
return SQLITE_UPSERT_QUERY

if settings.DATABASE_TYPE == "postgresql":
target = POSTGRES_CONFLICT_TARGETS[conflict_key]
conflict = POSTGRES_CONFLICT_MAP[conflict_key]
if conflict_key not in _POSTGRES_UPSERT_CACHE:
_POSTGRES_UPSERT_CACHE[conflict_key] = (
TORRENT_INSERT_TEMPLATE
+ f" ON CONFLICT {target} "
+ f" ON CONFLICT {conflict['target']} {conflict['predicate']} "
+ POSTGRES_UPDATE_SET
)
return _POSTGRES_UPSERT_CACHE[conflict_key]
Expand Down
4 changes: 3 additions & 1 deletion comet/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,10 @@
</div>

<div class="form-item">
<sl-select id="debridService" value="torrent" label="Debrid Service" placeholder="Select debrid service">
<sl-select id="debridService" value="{{ 'torrent' if not disableTorrentStreams else 'realdebrid' }}" label="Debrid Service" placeholder="Select debrid service">
{% if not disableTorrentStreams %}
<sl-option value="torrent">Torrent</sl-option>
{% endif %}
<sl-option value="torbox">TorBox</sl-option>
<sl-option value="debrider">Debrider</sl-option>
<sl-option value="easydebrid">EasyDebrid</sl-option>
Expand Down
25 changes: 19 additions & 6 deletions comet/utils/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,31 @@ def default_dump(obj):
return obj.model_dump()


def _safe_int(value, fallback=None):
try:
if value is None:
return fallback
text = str(value).strip()
if text == "":
return fallback
return int(text)
except (TypeError, ValueError):
return fallback


def parse_media_id(media_type: str, media_id: str):
if "kitsu" in media_id:
info = media_id.split(":")

if len(info) > 2:
return info[1], 1, int(info[2])
else:
return info[1], 1, None
identifier = info[1] if len(info) > 1 else media_id
episode = _safe_int(info[2] if len(info) > 2 else None)
return identifier, 1, episode

if media_type == "series":
info = media_id.split(":")
return info[0], int(info[1]), int(info[2])
identifier = info[0]
season = _safe_int(info[1] if len(info) > 1 else None, fallback=1)
episode = _safe_int(info[2] if len(info) > 2 else None)
return identifier, season, episode

return media_id, None, None

Expand Down
Loading