mirror of
https://github.com/g0ldyy/comet.git
synced 2026-01-12 01:16:12 +01:00
feat: implement HTTP caching mechanism
This commit is contained in:
+27
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,7 +227,9 @@ async def stream(
|
||||
|
||||
if not is_released:
|
||||
logger.log("FILTER", f"🚫 {media_id} is not released yet. Skipping.")
|
||||
return {
|
||||
return _build_stream_response(
|
||||
request,
|
||||
{
|
||||
"streams": [
|
||||
{
|
||||
"name": "[🚫] Comet",
|
||||
@@ -191,7 +237,9 @@ async def stream(
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user