from __future__ import annotations from functools import lru_cache from typing import Literal from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True, ) app_name: str = Field(default="Rastro", alias="APP_NAME") app_description: str = Field( default="Observatorio publico de trackers BitTorrent", alias="APP_DESCRIPTION", ) app_version: str = Field(default="0.1.0", alias="APP_VERSION") public_url: str = Field(default="http://localhost:8090", alias="PUBLIC_URL") repository_url: str = Field(default="", alias="REPOSITORY_URL") database_url: str = Field( default="sqlite+aiosqlite:///data/rastro.db", alias="DATABASE_URL", ) host: str = Field(default="0.0.0.0", alias="HOST") port: int = Field(default=8090, alias="PORT", ge=1, le=65535) log_level: str = Field(default="INFO", alias="LOG_LEVEL") run_scheduler: bool = Field(default=True, alias="RUN_SCHEDULER") # Source lists refresh on a weekly cron (UTC). A startup import still runs # when the inventory is empty or the last successful import is stale. import_day_of_week: str = Field(default="sun", alias="IMPORT_DAY_OF_WEEK") import_hour_utc: int = Field(default=4, alias="IMPORT_HOUR_UTC", ge=0, le=23) import_max_age_hours: int = Field(default=144, alias="IMPORT_MAX_AGE_HOURS", ge=1) default_probe_interval_seconds: int = Field( default=10800, alias="DEFAULT_PROBE_INTERVAL_SECONDS", ge=60 ) min_probe_interval_seconds: int = Field(default=1800, alias="MIN_PROBE_INTERVAL_SECONDS", ge=60) probe_concurrency: int = Field(default=10, alias="PROBE_CONCURRENCY", ge=1, le=100) probe_timeout_seconds: float = Field(default=10.0, alias="PROBE_TIMEOUT_SECONDS", gt=0, le=120) max_response_bytes: int = Field(default=1_048_576, alias="MAX_RESPONSE_BYTES", ge=1024) raw_result_retention_days: int = Field(default=90, alias="RAW_RESULT_RETENTION_DAYS", ge=1) enable_ipv4: bool = Field(default=True, alias="ENABLE_IPV4") enable_ipv6: Literal["true", "false", "auto"] | bool = Field( default="auto", alias="ENABLE_IPV6" ) geoip_asn_db: str = Field(default="", alias="GEOIP_ASN_DB") # Source URLs (overridable for tests) trackerslist_all_url: str = Field( default=("https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt"), alias="TRACKERSLIST_ALL_URL", ) trackerslist_best_url: str = Field( default=("https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt"), alias="TRACKERSLIST_BEST_URL", ) newtrackon_all_url: str = Field( default="https://newtrackon.com/api/all", alias="NEWTRACKON_ALL_URL", ) newtrackon_stable_url: str = Field( default="https://newtrackon.com/api/stable", alias="NEWTRACKON_STABLE_URL", ) newtrackon_live_url: str = Field( default="https://newtrackon.com/api/live", alias="NEWTRACKON_LIVE_URL", ) jokepool710_all_url: str = Field( default=( "https://raw.githubusercontent.com/jokepool710/Torrent-_Trackers/main/trackers_all.txt" ), alias="JOKEPOOL710_ALL_URL", ) xiu2_all_url: str = Field( default="https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt", alias="XIU2_ALL_URL", ) xiu2_best_url: str = Field( default="https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt", alias="XIU2_BEST_URL", ) @field_validator("enable_ipv6", mode="before") @classmethod def normalize_ipv6(cls, value: object) -> object: if isinstance(value, str): lowered = value.strip().lower() if lowered in {"1", "yes", "true"}: return True if lowered in {"0", "no", "false"}: return False if lowered == "auto": return "auto" return value @model_validator(mode="after") def validate_intervals(self) -> Settings: if self.min_probe_interval_seconds > self.default_probe_interval_seconds: raise ValueError( "MIN_PROBE_INTERVAL_SECONDS cannot exceed DEFAULT_PROBE_INTERVAL_SECONDS" ) return self def user_agent(self) -> str: base = f"{self.app_name}/{self.app_version}" url = (self.public_url or "").strip() if url: return f"{base} (+{url})" return base def peer_id_prefix(self) -> bytes: """Return a 8-byte BitTorrent peer_id style prefix derived from app name.""" slug = "".join(c for c in self.app_name.upper() if c.isalnum())[:6] or "RASTRO" slug = slug.ljust(6, "X")[:6] return f"-{slug}-".encode("ascii") def ipv6_mode(self) -> Literal["true", "false", "auto"]: if self.enable_ipv6 is True: return "true" if self.enable_ipv6 is False: return "false" return "auto" @lru_cache def get_settings() -> Settings: return Settings()