Skip to content
Draft
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
1 change: 1 addition & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ CONFIGURE_PAGE_PASSWORD= # Optional: protect /configure and /{config}/configure
CONFIGURE_PAGE_SESSION_TTL=86400 # Session lifetime for the configuration page login cookie (seconds)
PUBLIC_API_TOKEN= # Optional: token used in the Stremio API prefix /s/<token>. If empty and CONFIGURE_PAGE_PASSWORD is set, Comet auto-generates one.
PUBLIC_API_TOKEN_FILE=data/public_api_token.txt # File used to persist the auto-generated PUBLIC_API_TOKEN outside the database
SERIOUSSPORTSYNC_ALLOWED_HOSTS= # Optional comma-separated host:port allowlist for SeriousSportSync manifest URLs (e.g. serioussportsync:7000,192.168.1.16:7001)

# ============================== #
# Database Configuration #
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- [Kitsu](https://kitsu.io/) support (anime)
- Adult Content Filter
- ChillLink Protocol support
- **SeriousSportSync event search**: Resolve sports-event IDs into title/date-aware searches across compatible Comet scrapers ([documentation](docs/integrations/serioussportsync.md))

# Installation
To customize your Comet experience to suit your needs, please first take a look at all the [environment variables](https://github.com/g0ldyy/comet/blob/main/.env-sample)!
Expand Down
69 changes: 58 additions & 11 deletions comet/api/endpoints/playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
get_debrid_credentials,
)
from comet.metadata.manager import MetadataScraper
from comet.metadata.serioussportsync import (
SeriousSportSyncResolverError,
is_serioussportsync_event_id,
resolve_serioussportsync_event,
)
from comet.services.status_video import build_status_video_response
from comet.services.streaming.manager import custom_handle_stream_request
from comet.utils.http_client import http_client_manager
Expand Down Expand Up @@ -115,6 +120,51 @@ def _build_playback_media_id(
return f"{media_only_id}:{season}:{episode}"


async def _resolve_playback_metadata(
session,
config: dict,
context_media_id: str,
media_type: str | None,
season: int | None,
episode: int | None,
) -> tuple[str, str, dict]:
resolved_media_type = media_type or (
"series" if season is not None else "movie"
)
serioussportsync_manifest_url = config.get("seriousSportsSyncManifestUrl") or ""
if serioussportsync_manifest_url and is_serioussportsync_event_id(
resolved_media_type, context_media_id
):
aliases = {}
try:
event = await resolve_serioussportsync_event(
session,
serioussportsync_manifest_url,
resolved_media_type,
context_media_id,
)
aliases = {"ez": list(event.search_titles)}
except SeriousSportSyncResolverError as error:
# Playback still has an exact torrent name and file index. Do not
# turn a temporary metadata outage into a server error.
logger.warning(
"Unable to refresh SeriousSportSync playback aliases for "
f"{context_media_id}: {error}"
)
return context_media_id, context_media_id, aliases

full_media_id = _build_playback_media_id(
context_media_id,
resolved_media_type,
season,
episode,
)
_, aliases = await MetadataScraper(session).fetch_metadata_and_aliases(
resolved_media_type, full_media_id
)
return full_media_id, context_media_id, aliases


async def cache_download_link(
*,
debrid_service: str,
Expand Down Expand Up @@ -314,14 +364,16 @@ async def playback(
debrid_video_id = None
debrid_media_only_id = context_media_id
if context_media_id:
metadata_scraper = MetadataScraper(session)
resolved_media_type = media_type or (
"series" if season is not None else "movie"
)
try:
full_media_id = _build_playback_media_id(
(
debrid_video_id,
debrid_media_only_id,
aliases,
) = await _resolve_playback_metadata(
session,
config,
context_media_id,
resolved_media_type,
media_type,
season,
episode,
)
Expand All @@ -331,11 +383,6 @@ async def playback(
default_key="BAD_REQUEST",
)

debrid_video_id = full_media_id
_, aliases = await metadata_scraper.fetch_metadata_and_aliases(
resolved_media_type, full_media_id
)

debrid = get_debrid(
session,
debrid_video_id,
Expand Down
19 changes: 19 additions & 0 deletions comet/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import string
import time
from collections.abc import Awaitable, Callable
from urllib.parse import urlsplit

import RTN
from databases import Database
Expand Down Expand Up @@ -1282,6 +1283,7 @@ class ConfigModel(BaseModel):
enableTorrent: bool | None = False
deduplicateStreams: bool | None = False
scrapeDebridAccountTorrents: bool | None = False
seriousSportsSyncManifestUrl: str | None = ""

debridStreamProxyPassword: str | None = ""
languages: dict | None = rtn_settings_default_dumped["languages"]
Expand Down Expand Up @@ -1323,6 +1325,23 @@ def validate_debrid_services(cls, v):
]
return v

@field_validator("seriousSportsSyncManifestUrl")
def validate_serioussportsync_manifest_url(cls, v):
if v is None or v == "":
return ""
if not isinstance(v, str):
raise ValueError("SeriousSportSync manifest URL must be a string")
parsed = urlsplit(v.strip())
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or not parsed.path.endswith("/manifest.json")
or parsed.username is not None
or parsed.password is not None
):
raise ValueError("Invalid SeriousSportSync manifest URL")
return v.strip()


default_config = ConfigModel().model_dump()
default_config["rtnSettings"] = rtn_settings_default
Expand Down
1 change: 1 addition & 0 deletions comet/core/server_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ class ServerSettings(BaseSettings):
FASTAPI_HOST: str | None = "0.0.0.0"
FASTAPI_PORT: int | None = 8000
FASTAPI_WORKERS: int | None = 1
SERIOUSSPORTSYNC_ALLOWED_HOSTS: str | None = ""
USE_GUNICORN: bool | None = True
GUNICORN_PRELOAD_APP: bool | None = True
133 changes: 133 additions & 0 deletions comet/metadata/serioussportsync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import re
from dataclasses import dataclass
from urllib.parse import quote, urlsplit, urlunsplit

from comet.core.models import settings

_EVENT_ID = re.compile(r"[a-z0-9][a-z0-9_-]*:[A-Za-z0-9][A-Za-z0-9._-]*")
_FIXTURE_SEPARATOR = re.compile(
r"\s+(?:v(?:s)?\.?|versus|at)\s+|\s*@\s*", re.IGNORECASE
)
_SPLIT_EVENT_DAY_SUFFIX = re.compile(
r"\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)$",
re.IGNORECASE,
)


class SeriousSportSyncResolverError(RuntimeError):
pass


@dataclass(frozen=True, slots=True)
class SeriousSportSyncEvent:
title: str
search_titles: tuple[str, ...]
date: str | None
year: int | None
country: str | None


def is_serioussportsync_event_id(media_type: str, media_id: str) -> bool:
return (
media_type == "movie"
and media_id != "kitsu"
and _EVENT_ID.fullmatch(media_id) is not None
and not media_id.startswith("kitsu:")
)


def build_event_search_titles(title: str, aliases: list[str]) -> tuple[str, ...]:
titles = []
seen = set()

def append(candidate: object):
if not isinstance(candidate, str):
return
candidate = " ".join(candidate.split())
identity = candidate.casefold()
if not candidate or identity in seen:
return
seen.add(identity)
titles.append(candidate)

for candidate in (title, *aliases):
append(candidate)
if isinstance(candidate, str):
append(_FIXTURE_SEPARATOR.sub(" ", candidate))
# Some indexers use strict full-text matching and return no results for
# split-day labels such as "WWE SummerSlam Sunday", even though the
# release is indexed under the shared event name "WWE SummerSlam".
# Keep the precise forms first, then add the safe base-event form.
base_event = _SPLIT_EVENT_DAY_SUFFIX.sub("", candidate)
if base_event != candidate:
append(base_event)
append(_FIXTURE_SEPARATOR.sub(" ", base_event))
return tuple(titles[:24])


def _allowed_manifest_url(manifest_url: str):
parsed = urlsplit(manifest_url)
allowed_hosts = {
host.strip().casefold()
for host in (settings.SERIOUSSPORTSYNC_ALLOWED_HOSTS or "").split(",")
if host.strip()
}
if parsed.netloc.casefold() not in allowed_hosts:
raise SeriousSportSyncResolverError(
"SeriousSportSync manifest host is not allowed by this Comet instance"
)
return parsed


async def resolve_serioussportsync_event(
session,
manifest_url: str,
media_type: str,
media_id: str,
) -> SeriousSportSyncEvent:
parsed = _allowed_manifest_url(manifest_url)
context_path = parsed.path.removesuffix("manifest.json") + (
f"search-context/{quote(media_type, safe='')}/{quote(media_id, safe='')}.json"
)
context_url = urlunsplit((parsed.scheme, parsed.netloc, context_path, "", ""))

try:
async with session.get(context_url) as response:
if response.status != 200:
raise SeriousSportSyncResolverError(
f"SeriousSportSync returned HTTP {response.status}"
)
payload = await response.json()
except SeriousSportSyncResolverError:
raise
except Exception as error:
raise SeriousSportSyncResolverError(
f"Unable to resolve SeriousSportSync event: {error}"
) from error

if not isinstance(payload, dict):
raise SeriousSportSyncResolverError("Invalid SeriousSportSync response")
title = payload.get("title")
aliases = payload.get("searchTitles", [])
date = payload.get("date")
year = payload.get("year")
country = payload.get("country")
if not isinstance(title, str) or not title.strip():
raise SeriousSportSyncResolverError("SeriousSportSync response has no title")
if not isinstance(aliases, list):
aliases = []
aliases = [alias for alias in aliases if isinstance(alias, str) and alias.strip()]
if date is not None and not isinstance(date, str):
date = None
if not isinstance(year, int):
year = None
if country is not None and not isinstance(country, str):
country = None

return SeriousSportSyncEvent(
title=title.strip(),
search_titles=build_event_search_titles(title, aliases),
date=date,
year=year,
country=country,
)
Loading