Merge pull request #413 from g0ldyy/feat/digital-release-filter

feat: implement digital release filtering for movies and series using TMDB
This commit is contained in:
Goldy
2025-12-28 16:33:15 +01:00
committed by GitHub
8 changed files with 254 additions and 1 deletions
+2
View File
@@ -221,6 +221,8 @@ TORRENT_DISABLED_STREAM_URL=https://comet.fast # Optional URL included in the pl
# Content Filtering #
# ============================== #
REMOVE_ADULT_CONTENT=False
DIGITAL_RELEASE_FILTER=False # Filter unreleased content
TMDB_READ_ACCESS_TOKEN= # Optional: Provide your own TMDB Read Access Token to avoid using the shared default key
# ============================== #
# UI Customization #
+23 -1
View File
@@ -9,6 +9,7 @@ from comet.core.config_validation import config_check
from comet.core.logger import logger
from comet.core.models import database, settings, trackers
from comet.debrid.manager import get_debrid_extension
from comet.metadata.filter import release_filter
from comet.metadata.manager import MetadataScraper
from comet.services.debrid import DebridService
from comet.services.lock import DistributedLock, is_scrape_in_progress
@@ -135,6 +136,9 @@ async def stream(
b64config: str = None,
chilllink: bool = False,
):
if media_type not in ["movie", "series"]:
return {"streams": []}
if "tmdb:" in media_id:
return {"streams": []}
@@ -167,8 +171,26 @@ async def stream(
async with aiohttp.ClientSession(connector=connector) as session:
metadata_scraper = MetadataScraper(session)
# First, check if metadata is already cached
# Digital Release Filter
id, season, episode = parse_media_id(media_type, media_id)
is_released = await release_filter.check_is_released(
session, media_type, media_id, season, episode
)
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.fast",
}
]
}
# Check if metadata is already cached
cached_metadata = await metadata_scraper.get_cached(
id, season if "kitsu" not in media_id else 1, episode
)
+25
View File
@@ -361,6 +361,16 @@ async def setup_database():
"""
)
await database.execute(
"""
CREATE TABLE IF NOT EXISTS digital_release_cache (
media_id TEXT PRIMARY KEY,
release_date INTEGER,
timestamp INTEGER
)
"""
)
# =============================================================================
# TORRENTS TABLE INDEXES - Most critical for performance
# =============================================================================
@@ -617,6 +627,13 @@ async def setup_database():
"""
)
await database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_digital_release_timestamp
ON digital_release_cache (timestamp)
"""
)
if settings.DATABASE_TYPE == "sqlite":
await database.execute("PRAGMA busy_timeout=30000") # 30 seconds timeout
await database.execute("PRAGMA journal_mode=WAL")
@@ -697,6 +714,14 @@ async def _run_startup_cleanup():
{"cache_ttl": settings.DEBRID_CACHE_TTL, "current_time": current_time},
)
await database.execute(
"""
DELETE FROM digital_release_cache
WHERE timestamp + :cache_ttl < :current_time;
""",
{"cache_ttl": settings.METADATA_CACHE_TTL, "current_time": current_time},
)
await database.execute("DELETE FROM download_links_cache")
await database.execute(
+6
View File
@@ -62,6 +62,12 @@ CUSTOM_LOG_LEVELS = {
"loguru_color": "<fg #E24A90>",
"no": 20,
},
"FILTER": {
"color": "#FFD700",
"icon": "🛡️",
"loguru_color": "<fg #FFD700>",
"no": 35,
},
}
ALL_LOG_LEVELS = {**STANDARD_LOG_LEVELS, **CUSTOM_LOG_LEVELS}
+7
View File
@@ -390,6 +390,13 @@ def log_startup_info(settings):
)
logger.log("COMET", f"Remove Adult Content: {bool(settings.REMOVE_ADULT_CONTENT)}")
logger.log(
"COMET", f"Digital Release Filter: {bool(settings.DIGITAL_RELEASE_FILTER)}"
)
logger.log(
"COMET",
f"TMDB Read Access Token: {settings.TMDB_READ_ACCESS_TOKEN if settings.TMDB_READ_ACCESS_TOKEN else 'Shared'}",
)
logger.log("COMET", f"Custom Header HTML: {bool(settings.CUSTOM_HEADER_HTML)}")
background_scraper_display = (
+2
View File
@@ -129,6 +129,8 @@ class AppSettings(BaseSettings):
BACKGROUND_SCRAPER_MAX_SERIES_PER_RUN: Optional[int] = 100
ANIME_MAPPING_SOURCE: Optional[str] = "remote"
ANIME_MAPPING_REFRESH_INTERVAL: Optional[int] = 86400
DIGITAL_RELEASE_FILTER: Optional[bool] = False
TMDB_READ_ACCESS_TOKEN: Optional[str] = None
@field_validator("INDEXER_MANAGER_TYPE")
def set_indexer_manager_type(cls, v, values):
+103
View File
@@ -0,0 +1,103 @@
import time
from datetime import datetime
from comet.core.logger import logger
from comet.core.models import database, settings
from comet.metadata.tmdb import TMDBApi
class DigitalReleaseFilter:
async def check_is_released(
self,
session,
media_type: str,
media_id: str,
season: int = None,
episode: int = None,
):
if not settings.DIGITAL_RELEASE_FILTER:
return True
try:
cached_date = await database.fetch_val(
"""
SELECT release_date FROM digital_release_cache
WHERE media_id = :media_id
AND timestamp + :cache_ttl >= :current_time
""",
{
"media_id": media_id,
"cache_ttl": settings.METADATA_CACHE_TTL,
"current_time": time.time(),
},
)
if cached_date is not None:
return self._is_released(cached_date)
tmdb_id = None
tmdb = TMDBApi(session)
if media_id.startswith("tt"):
imdb_id = media_id.split(":")[0]
tmdb_id = await tmdb.get_tmdb_id_from_imdb(imdb_id)
else:
# Other formats (e.g. kitsu) are not supported
return True
if not tmdb_id:
logger.warning(
f"DigitalReleaseFilter: Could not resolve {media_id} to TMDB ID. Allowing search."
)
return True
release_date_str = None
if media_type == "movie":
release_date_str = await tmdb.get_upcoming_movie_release_date(tmdb_id)
elif media_type == "series":
release_date_str = await tmdb.get_episode_air_date(
tmdb_id, season, episode
)
cache_timestamp = int(time.time())
if release_date_str is None:
# Not found, treat as released in far future to block
release_date_timestamp = 253402300799 # 9999-12-31
# Cache for only 1 day (86400s) to recheck later
if settings.METADATA_CACHE_TTL > 86400:
cache_timestamp = int(
time.time() - settings.METADATA_CACHE_TTL + 86400
)
else:
release_date_timestamp = int(
datetime.strptime(release_date_str, "%Y-%m-%d").timestamp()
)
await database.execute(
"""
INSERT INTO digital_release_cache (media_id, release_date, timestamp)
VALUES (:media_id, :release_date, :timestamp)
ON CONFLICT (media_id) DO UPDATE SET release_date = :release_date, timestamp = :timestamp
""",
{
"media_id": media_id,
"release_date": release_date_timestamp,
"timestamp": cache_timestamp,
},
)
return self._is_released(release_date_timestamp)
except Exception as e:
logger.error(
f"DigitalReleaseFilter: Error checking release status for {media_id}: {e}"
)
return True
def _is_released(self, release_timestamp: float):
if release_timestamp is None:
return True
return release_timestamp <= time.time()
release_filter = DigitalReleaseFilter()
+86
View File
@@ -0,0 +1,86 @@
import aiohttp
from comet.core.logger import logger
from comet.core.models import settings
DEFAULT_TMDB_READ_ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJlNTkxMmVmOWFhM2IxNzg2Zjk3ZTE1NWY1YmQ3ZjY1MSIsInN1YiI6IjY1M2NjNWUyZTg5NGE2MDBmZjE2N2FmYyIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.xrIXsMFJpI1o1j5g2QpQcFP1X3AfRjFA5FlBFO5Naw8"
class TMDBApi:
def __init__(self, session: aiohttp.ClientSession):
self.session = session
self.base_url = "https://api.themoviedb.org/3"
self.headers = {
"Authorization": f"Bearer {settings.TMDB_READ_ACCESS_TOKEN if settings.TMDB_READ_ACCESS_TOKEN else DEFAULT_TMDB_READ_ACCESS_TOKEN}",
"Content-Type": "application/json",
}
async def get_upcoming_movie_release_date(self, tmdb_id: str):
try:
url = f"{self.base_url}/movie/{tmdb_id}/release_dates"
async with self.session.get(url, headers=self.headers) as response:
if response.status != 200:
return None
data = await response.json()
release_dates = []
for result in data.get("results", []):
for release in result.get("release_dates", []):
if release.get("type") in [4, 5]: # Digital or Physical
date_str = release.get("release_date", "").split("T")[0]
if date_str:
release_dates.append(date_str)
if release_dates:
return min(release_dates)
# Fallback
url_details = f"{self.base_url}/movie/{tmdb_id}"
async with self.session.get(
url_details, headers=self.headers
) as resp_details:
if resp_details.status == 200:
details = await resp_details.json()
return details.get("release_date")
except Exception as e:
logger.error(f"TMDB: Error getting movie release date for {tmdb_id}: {e}")
return None
async def get_episode_air_date(self, tmdb_id: str, season: int, episode: int):
try:
url = f"{self.base_url}/tv/{tmdb_id}/season/{season}/episode/{episode}"
async with self.session.get(url, headers=self.headers) as response:
if response.status != 200:
return None
data = await response.json()
return data.get("air_date")
except Exception as e:
logger.error(
f"TMDB: Error getting episode air date for {tmdb_id} S{season}E{episode}: {e}"
)
return None
async def get_tmdb_id_from_imdb(self, imdb_id: str):
try:
url = f"{self.base_url}/find/{imdb_id}?external_source=imdb_id"
async with self.session.get(url, headers=self.headers) as response:
if response.status != 200:
text = await response.text()
logger.error(
f"TMDB: Failed to get TMDB ID from IMDB ID {imdb_id}: {text}"
)
return None
data = await response.json()
if data.get("movie_results"):
return str(data["movie_results"][0]["id"])
if data.get("tv_results"):
return str(data["tv_results"][0]["id"])
return None
except Exception as e:
logger.error(f"TMDB: Error converting IMDB ID {imdb_id}: {e}")
return None