feat: implement HTTP caching mechanism

This commit is contained in:
g0ldyy
2026-01-09 22:33:35 +01:00
parent 06bbe9bbd8
commit 35eab54a7c
9 changed files with 342 additions and 24 deletions
+27
View File
@@ -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
+8 -1
View File
@@ -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
+15
View File
@@ -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
+2 -1
View File
@@ -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()
+71 -15
View File
@@ -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,
)
+4
View File
@@ -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):
+1 -1
View File
@@ -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):
+214
View File
@@ -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)
-6
View File
@@ -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",