From 35eab54a7caa68f152583014680e7605619f36d4 Mon Sep 17 00:00:00 2001 From: g0ldyy <153996346+g0ldyy@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:33:35 +0100 Subject: [PATCH] feat: implement HTTP caching mechanism --- .env-sample | 27 ++++ comet/api/endpoints/config.py | 9 +- comet/api/endpoints/manifest.py | 15 ++ comet/api/endpoints/playback.py | 3 +- comet/api/endpoints/stream.py | 86 +++++++++-- comet/core/models.py | 4 + comet/services/streaming/manager.py | 2 +- comet/utils/cache.py | 214 ++++++++++++++++++++++++++++ comet/utils/network.py | 6 - 9 files changed, 342 insertions(+), 24 deletions(-) create mode 100644 comet/utils/cache.py diff --git a/.env-sample b/.env-sample index 32ca4ba..cdd63af 100644 --- a/.env-sample +++ b/.env-sample @@ -263,3 +263,30 @@ CUSTOM_HEADER_HTML=None # StremThru Integration # # ============================== # STREMTHRU_URL=https://stremthru.13377001.xyz # needed to use debrid services + +# ============================== # +# HTTP Cache Settings # +# ============================== # +# These settings control HTTP-level caching. +# Comet sends proper Cache-Control headers that can be respected by proxies. +# +# Key cacheable endpoints: +# - /stream/{type}/{id}.json (PUBLIC - shared cache, no user config) +# - /{config}/stream/... (PRIVATE - user-specific, browser only) +# - /configure (PUBLIC/PRIVATE - depends on CUSTOM_HEADER_HTML) + +HTTP_CACHE_ENABLED=False # Master switch for HTTP cache headers + +# PUBLIC streams TTL (for /stream/movie/tt1234567.json without user config) +# These can be cached at edge and shared between all users. +# Higher = less origin traffic, lower = fresher results +HTTP_CACHE_PUBLIC_STREAMS_TTL=300 # 5 minutes (recommended: 120-600) + +# PRIVATE streams TTL (for /{config}/stream/... with user config) +# Only cached in user's browser (private cache). +# These contain user-specific debrid availability info. +HTTP_CACHE_PRIVATE_STREAMS_TTL=60 # 1 minute (recommended: 30-120) + +# Stale-While-Revalidate: serve stale content while fetching fresh in background +# Improves perceived performance - users get instant response with cached data +HTTP_CACHE_STALE_WHILE_REVALIDATE=60 # 1 minute diff --git a/comet/api/endpoints/config.py b/comet/api/endpoints/config.py index 97525de..3eba004 100644 --- a/comet/api/endpoints/config.py +++ b/comet/api/endpoints/config.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Request from fastapi.templating import Jinja2Templates from comet.core.models import settings, web_config +from comet.utils.cache import CachePolicies router = APIRouter() templates = Jinja2Templates("comet/templates") @@ -20,7 +21,7 @@ templates = Jinja2Templates("comet/templates") description="Renders the configuration page with existing configuration.", ) async def configure(request: Request): - return templates.TemplateResponse( + response = templates.TemplateResponse( "index.html", { "request": request, @@ -32,3 +33,9 @@ async def configure(request: Request): "disableTorrentStreams": settings.DISABLE_TORRENT_STREAMS, }, ) + + if settings.HTTP_CACHE_ENABLED: + response.headers["Cache-Control"] = CachePolicies.configure_page().build() + response.headers["Vary"] = "Accept, Accept-Encoding" + + return response diff --git a/comet/api/endpoints/manifest.py b/comet/api/endpoints/manifest.py index d669a32..8d44588 100644 --- a/comet/api/endpoints/manifest.py +++ b/comet/api/endpoints/manifest.py @@ -6,6 +6,9 @@ from fastapi import APIRouter, Request from comet.core.config_validation import config_check from comet.core.models import settings from comet.debrid.manager import get_debrid_extension +from comet.utils.cache import (CachedJSONResponse, CachePolicies, + check_etag_match, generate_etag, + not_modified_response) router = APIRouter() @@ -54,4 +57,16 @@ async def manifest(request: Request, b64config: str = None): f"{settings.ADDON_NAME}{(' | ' + debrid_extension) if debrid_extension != 'TORRENT' else ''}" ) + if settings.HTTP_CACHE_ENABLED: + etag = generate_etag(base_manifest) + if check_etag_match(request, etag): + return not_modified_response(etag) + + return CachedJSONResponse( + content=base_manifest, + cache_control=CachePolicies.manifest(), + etag=etag, + vary=["Accept", "Accept-Encoding"], + ) + return base_manifest diff --git a/comet/api/endpoints/playback.py b/comet/api/endpoints/playback.py index 020b031..72b7d80 100644 --- a/comet/api/endpoints/playback.py +++ b/comet/api/endpoints/playback.py @@ -11,7 +11,8 @@ from comet.core.models import database, settings from comet.debrid.manager import get_debrid from comet.metadata.manager import MetadataScraper from comet.services.streaming.manager import custom_handle_stream_request -from comet.utils.network import NO_CACHE_HEADERS, get_client_ip +from comet.utils.cache import NO_CACHE_HEADERS +from comet.utils.network import get_client_ip from comet.utils.parsing import parse_optional_int router = APIRouter() diff --git a/comet/api/endpoints/stream.py b/comet/api/endpoints/stream.py index 63f3344..eaef725 100644 --- a/comet/api/endpoints/stream.py +++ b/comet/api/endpoints/stream.py @@ -16,6 +16,9 @@ from comet.services.anime import anime_mapper from comet.services.debrid import DebridService from comet.services.lock import DistributedLock, is_scrape_in_progress from comet.services.orchestration import TorrentManager +from comet.utils.cache import (CachedJSONResponse, CachePolicies, + check_etag_match, generate_etag, + not_modified_response) from comet.utils.formatting import (format_chilllink, format_title, get_formatted_components) from comet.utils.network import get_client_ip @@ -24,6 +27,38 @@ from comet.utils.parsing import parse_media_id streams = APIRouter() +def _build_stream_response( + request: Request, + content: dict, + is_public: bool = False, + vary_headers: list = None, +): + if not settings.HTTP_CACHE_ENABLED: + return content + + etag = generate_etag(content) + + if check_etag_match(request, etag): + return not_modified_response(etag) + + if is_public: + cache_policy = CachePolicies.public_torrents() + vary = ["Accept", "Accept-Encoding"] + else: + cache_policy = CachePolicies.private_streams() + vary = ["Accept", "Accept-Encoding", "Authorization"] + + if vary_headers: + vary.extend(vary_headers) + + return CachedJSONResponse( + content=content, + cache_control=cache_policy, + etag=etag, + vary=list(set(vary)), + ) + + async def is_first_search(media_id: str): params = {"media_id": media_id, "timestamp": time.time()} @@ -138,17 +173,23 @@ async def stream( b64config: str = None, chilllink: bool = False, ): + is_public_request = b64config is None + if media_type not in ["movie", "series"]: - return {"streams": []} + return _build_stream_response( + request, {"streams": []}, is_public=is_public_request + ) if "tmdb:" in media_id: - return {"streams": []} + return _build_stream_response( + request, {"streams": []}, is_public=is_public_request + ) media_id = media_id.replace("imdb_id:", "") config = config_check(b64config) if not config: - return { + error_response = { "streams": [ { "name": "[❌] Comet", @@ -157,6 +198,7 @@ async def stream( } ] } + return error_response is_torrent = config["debridService"] == "torrent" if settings.DISABLE_TORRENT_STREAMS and is_torrent: @@ -167,7 +209,9 @@ async def stream( if settings.TORRENT_DISABLED_STREAM_URL: placeholder_stream["url"] = settings.TORRENT_DISABLED_STREAM_URL - return {"streams": [placeholder_stream]} + return _build_stream_response( + request, {"streams": [placeholder_stream]}, is_public=is_public_request + ) connector = aiohttp.TCPConnector(limit=0) async with aiohttp.ClientSession(connector=connector) as session: @@ -183,15 +227,19 @@ async def stream( if not is_released: logger.log("FILTER", f"🚫 {media_id} is not released yet. Skipping.") - return { - "streams": [ - { - "name": "[🚫] Comet", - "description": "Content not digitally released yet.", - "url": "https://comet.feels.legal", - } - ] - } + return _build_stream_response( + request, + { + "streams": [ + { + "name": "[🚫] Comet", + "description": "Content not digitally released yet.", + "url": "https://comet.feels.legal", + } + ] + }, + is_public=is_public_request, + ) # Check if metadata is already cached cached_metadata = await metadata_scraper.get_from_cache_by_media_id( @@ -593,6 +641,14 @@ async def stream( non_cached_results.append(the_stream) if sort_mixed: - return {"streams": cached_results} + return _build_stream_response( + request, + {"streams": cached_results}, + is_public=is_public_request, + ) - return {"streams": cached_results + non_cached_results} + return _build_stream_response( + request, + {"streams": cached_results + non_cached_results}, + is_public=is_public_request, + ) diff --git a/comet/core/models.py b/comet/core/models.py index ddff6c9..ad1c044 100644 --- a/comet/core/models.py +++ b/comet/core/models.py @@ -137,6 +137,10 @@ class AppSettings(BaseSettings): RATELIMIT_MAX_RETRIES: Optional[int] = 3 RATELIMIT_RETRY_BASE_DELAY: Optional[float] = 1.0 RTN_FILTER_DEBUG: Optional[bool] = False + HTTP_CACHE_ENABLED: Optional[bool] = False + HTTP_CACHE_PUBLIC_STREAMS_TTL: Optional[int] = 300 + HTTP_CACHE_PRIVATE_STREAMS_TTL: Optional[int] = 60 + HTTP_CACHE_STALE_WHILE_REVALIDATE: Optional[int] = 60 @field_validator("INDEXER_MANAGER_TYPE") def set_indexer_manager_type(cls, v, values): diff --git a/comet/services/streaming/manager.py b/comet/services/streaming/manager.py index 56b6a28..75cb2dc 100644 --- a/comet/services/streaming/manager.py +++ b/comet/services/streaming/manager.py @@ -10,7 +10,7 @@ from comet.core.logger import logger from comet.core.models import database, settings from comet.services.bandwidth import bandwidth_monitor from comet.services.streaming.wrapper import monitored_handle_stream_request -from comet.utils.network import NO_CACHE_HEADERS +from comet.utils.cache import NO_CACHE_HEADERS async def on_stream_end(connection_id: str, ip: str): diff --git a/comet/utils/cache.py b/comet/utils/cache.py new file mode 100644 index 0000000..bcc44f4 --- /dev/null +++ b/comet/utils/cache.py @@ -0,0 +1,214 @@ +import hashlib +from typing import Any, Optional + +import orjson +from fastapi import Request, Response +from fastapi.responses import JSONResponse + +from comet.core.models import settings + +NO_CACHE_HEADERS = { + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", +} + + +class CacheControl: + def __init__(self): + self._directives = [] + self._max_age = None + self._s_maxage = None + self._stale_while_revalidate = None + self._stale_if_error = None + + def public(self): + """Response can be cached by any cache.""" + self._directives.append("public") + return self + + def private(self): + """Response is intended for a single user.""" + self._directives.append("private") + return self + + def no_cache(self): + """Cache must revalidate with origin before using cached copy.""" + self._directives.append("no-cache") + return self + + def no_store(self): + """Response must not be stored in any cache.""" + self._directives.append("no-store") + return self + + def must_revalidate(self): + """Cache must revalidate stale responses.""" + self._directives.append("must-revalidate") + return self + + def immutable(self): + """Response will not change during its freshness lifetime.""" + self._directives.append("immutable") + return self + + def max_age(self, seconds: int): + """Maximum time response is considered fresh (browser cache).""" + self._max_age = seconds + return self + + def s_maxage(self, seconds: int): + """Maximum time response is fresh for shared caches (CDN/proxy).""" + self._s_maxage = seconds + return self + + def stale_while_revalidate(self, seconds: int): + """Serve stale while revalidating in background.""" + self._stale_while_revalidate = seconds + return self + + def stale_if_error(self, seconds: int): + """Serve stale if origin returns error.""" + self._stale_if_error = seconds + return self + + def build(self): + """Build the Cache-Control header value.""" + parts = list(self._directives) + + if self._max_age is not None: + parts.append(f"max-age={self._max_age}") + if self._s_maxage is not None: + parts.append(f"s-maxage={self._s_maxage}") + if self._stale_while_revalidate is not None: + parts.append(f"stale-while-revalidate={self._stale_while_revalidate}") + if self._stale_if_error is not None: + parts.append(f"stale-if-error={self._stale_if_error}") + + return ", ".join(parts) + + +def generate_etag(data: Any): + if isinstance(data, bytes): + content = data + elif isinstance(data, str): + content = data.encode("utf-8") + else: + content = orjson.dumps(data, option=orjson.OPT_SORT_KEYS) + + hash_digest = hashlib.md5(content, usedforsecurity=False).hexdigest()[:16] + return f'W/"{hash_digest}"' + + +def check_etag_match(request: Request, etag: str): + if_none_match = request.headers.get("If-None-Match") + if not if_none_match: + return False + + client_etags = [e.strip() for e in if_none_match.split(",")] + + normalized_etag = etag.replace('W/"', '"') + for client_etag in client_etags: + normalized_client = client_etag.replace('W/"', '"') + if normalized_client == normalized_etag or client_etag == "*": + return True + + return False + + +class CachedJSONResponse(JSONResponse): + def __init__( + self, + content: Any, + status_code: int = 200, + cache_control: Optional[CacheControl] = None, + etag: Optional[str] = None, + vary: Optional[list[str]] = None, + **kwargs, + ): + body = orjson.dumps(content) + + super().__init__(content=content, status_code=status_code, **kwargs) + + self.body = body + + if cache_control: + self.headers["Cache-Control"] = cache_control.build() + + if etag: + self.headers["ETag"] = etag + else: + self.headers["ETag"] = generate_etag(body) + + if vary: + self.headers["Vary"] = ", ".join(vary) + + +def not_modified_response(etag: str): + return Response( + status_code=304, + headers={ + "ETag": etag, + "Cache-Control": "must-revalidate", + }, + ) + + +class CachePolicies: + @staticmethod + def public_torrents(): + """ + For public torrent lists (without user config). + Cache for a short time at CDN, revalidate often. + """ + + ttl = settings.HTTP_CACHE_PUBLIC_STREAMS_TTL + swr = settings.HTTP_CACHE_STALE_WHILE_REVALIDATE + + return ( + CacheControl() + .public() + .max_age(ttl // 2) # Browser cache shorter + .s_maxage(ttl) # CDN/proxy cache longer + .stale_while_revalidate(swr) + .stale_if_error(300) + ) + + @staticmethod + def private_streams(): + """ + For user-specific stream results (with b64config). + Private cache only, short TTL. + """ + + ttl = settings.HTTP_CACHE_PRIVATE_STREAMS_TTL + + return CacheControl().private().max_age(ttl).must_revalidate() + + @staticmethod + def manifest(): + """ + For manifest.json responses. + Very short cache as it can change based on config. + """ + return CacheControl().private().max_age(60).must_revalidate() + + @staticmethod + def configure_page(): + """ + For the /configure page. + Cacheable if no custom HTML, otherwise private. + """ + + if settings.CUSTOM_HEADER_HTML: + return CacheControl().private().max_age(300) + + return CacheControl().public().max_age(300).s_maxage(3600) + + @staticmethod + def no_cache(): + """ + For responses that should never be cached. + Used for playback redirects, errors, etc. + """ + return CacheControl().private().no_store().no_cache().max_age(0) diff --git a/comet/utils/network.py b/comet/utils/network.py index 0a58c22..b409c40 100644 --- a/comet/utils/network.py +++ b/comet/utils/network.py @@ -2,12 +2,6 @@ import ipaddress from fastapi import Request -NO_CACHE_HEADERS = { - "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", - "Pragma": "no-cache", - "Expires": "0", -} - IP_REQUEST_HEADERS = [ "X-Client-Ip", "Cf-Connecting-Ip",