Skip to main content
Glama
tools.py30.3 kB
"""Sync tools for the Trakt MCP server.""" import logging from collections.abc import Awaitable, Callable from typing import Any, Literal from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, Field, field_validator, model_validator from client.sync.client import SyncClient from config.api import DEFAULT_LIMIT from config.mcp.tools.sync import SYNC_TOOLS from models.formatters.sync_ratings import SyncRatingsFormatters from models.formatters.sync_watchlist import SyncWatchlistFormatters from models.sync.ratings import ( SyncRatingsSummary, TraktSyncRating, TraktSyncRatingItem, TraktSyncRatingsRequest, ) from models.sync.watchlist import ( SyncWatchlistSummary, TraktSyncWatchlistItem, TraktSyncWatchlistRequest, TraktWatchlistItem, ) from models.types.pagination import PaginatedResponse, PaginationParams from server.base import BaseToolErrorMixin from utils.api.errors import MCPError, handle_api_errors_func logger = logging.getLogger("trakt_mcp") WatchlistSortField = Literal[ "rank", "added", "title", "released", "runtime", "popularity", "percentage", "votes", ] # Type alias for tool handlers ToolHandler = Callable[..., Awaitable[str]] # Pydantic models for parameter validation class UserRatingsParams(BaseModel): """Parameters for fetching user ratings.""" rating_type: Literal["movies", "shows", "seasons", "episodes"] = "movies" rating: int | None = Field( default=None, ge=1, le=10, description="Optional specific rating to filter by" ) page: int | None = Field( default=None, ge=1, description="Optional page number for pagination (1-based)" ) @field_validator("rating", mode="before") @classmethod def _validate_rating(cls, v: object) -> object: if v is None or v == "": return None return v @field_validator("page", mode="before") @classmethod def _validate_page(cls, v: object) -> object: if v is None or v == "": return None return v class UserRatingRequestItem(BaseModel): """Single rating item for add/remove operations.""" rating: int = Field(ge=1, le=10, description="Rating from 1 to 10") trakt_id: str | None = Field(default=None, min_length=1, description="Trakt ID") imdb_id: str | None = Field(default=None, min_length=1, description="IMDB ID") tmdb_id: str | None = Field(default=None, min_length=1, description="TMDB ID") title: str | None = Field(default=None, min_length=1, description="Title") year: int | None = Field(default=None, gt=1800, description="Release year") @field_validator("trakt_id", "imdb_id", "tmdb_id", "title", mode="before") @classmethod def _strip_strings(cls, v: object) -> object: return v.strip() if isinstance(v, str) else v @field_validator("trakt_id", mode="after") @classmethod def _validate_trakt_id_numeric(cls, v: str | None) -> str | None: """Ensure trakt_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"trakt_id must be numeric, got: '{v}'") return v @field_validator("tmdb_id", mode="after") @classmethod def _validate_tmdb_id_numeric(cls, v: str | None) -> str | None: """Ensure tmdb_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"tmdb_id must be numeric, got: '{v}'") return v @model_validator(mode="after") def _validate_identifiers(self) -> "UserRatingRequestItem": """Ensure at least one identifier (trakt_id/imdb_id/tmdb_id) OR both title+year are provided.""" has_id = any([self.trakt_id, self.imdb_id, self.tmdb_id]) has_title_year = self.title and self.year if not has_id and not has_title_year: raise ValueError( "Rating item must include either an identifier (trakt_id, imdb_id, or tmdb_id) " + "or both title and year for proper identification" ) return self class UserRatingIdentifier(BaseModel): """Rating item identifier for removal operations (no rating required).""" trakt_id: str | None = Field(default=None, min_length=1, description="Trakt ID") imdb_id: str | None = Field(default=None, min_length=1, description="IMDB ID") tmdb_id: str | None = Field(default=None, min_length=1, description="TMDB ID") title: str | None = Field(default=None, min_length=1, description="Title") year: int | None = Field(default=None, gt=1800, description="Release year") @field_validator("trakt_id", "imdb_id", "tmdb_id", "title", mode="before") @classmethod def _strip_strings(cls, v: object) -> object: return v.strip() if isinstance(v, str) else v @field_validator("trakt_id", mode="after") @classmethod def _validate_trakt_id_numeric(cls, v: str | None) -> str | None: """Ensure trakt_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"trakt_id must be numeric, got: '{v}'") return v @field_validator("tmdb_id", mode="after") @classmethod def _validate_tmdb_id_numeric(cls, v: str | None) -> str | None: """Ensure tmdb_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"tmdb_id must be numeric, got: '{v}'") return v @model_validator(mode="after") def _validate_identifiers(self) -> "UserRatingIdentifier": """Ensure at least one identifier (trakt_id/imdb_id/tmdb_id) OR both title+year are provided.""" has_id = any([self.trakt_id, self.imdb_id, self.tmdb_id]) has_title_year = self.title and self.year if not has_id and not has_title_year: raise ValueError( "Rating item must include either an identifier (trakt_id, imdb_id, or tmdb_id) " + "or both title and year for proper identification" ) return self class UserRatingRequest(BaseModel): """Parameters for adding/removing user ratings.""" rating_type: Literal["movies", "shows", "seasons", "episodes"] items: list[UserRatingRequestItem] = Field( min_length=1, description="List of items to rate" ) class UserWatchlistParams(BaseModel): """Parameters for fetching user watchlist.""" watchlist_type: Literal["all", "movies", "shows", "seasons", "episodes"] = "all" sort_by: WatchlistSortField = "rank" sort_how: Literal["asc", "desc"] = "asc" page: int | None = Field( default=None, ge=1, description="Optional page number for pagination (1-based)" ) @field_validator("page", mode="before") @classmethod def _validate_page(cls, v: object) -> object: if v is None or v == "": return None return v class UserWatchlistRequestItem(BaseModel): """Single watchlist item for add operations (with optional notes).""" trakt_id: str | None = Field(default=None, min_length=1, description="Trakt ID") imdb_id: str | None = Field(default=None, min_length=1, description="IMDB ID") tmdb_id: str | None = Field(default=None, min_length=1, description="TMDB ID") title: str | None = Field(default=None, min_length=1, description="Title") year: int | None = Field(default=None, gt=1800, description="Release year") notes: str | None = Field( default=None, max_length=500, description="Optional notes (VIP only, 500 char max)", ) @field_validator("trakt_id", "imdb_id", "tmdb_id", "title", "notes", mode="before") @classmethod def _strip_strings(cls, v: object) -> object: return v.strip() if isinstance(v, str) else v @field_validator("trakt_id", mode="after") @classmethod def _validate_trakt_id_numeric(cls, v: str | None) -> str | None: """Ensure trakt_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"trakt_id must be numeric, got: '{v}'") return v @field_validator("tmdb_id", mode="after") @classmethod def _validate_tmdb_id_numeric(cls, v: str | None) -> str | None: """Ensure tmdb_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"tmdb_id must be numeric, got: '{v}'") return v @model_validator(mode="after") def _validate_identifiers(self) -> "UserWatchlistRequestItem": """Ensure at least one identifier (trakt_id/imdb_id/tmdb_id) OR both title+year are provided.""" has_id = any([self.trakt_id, self.imdb_id, self.tmdb_id]) has_title_year = self.title and self.year if not has_id and not has_title_year: raise ValueError( "Watchlist item must include either an identifier (trakt_id, imdb_id, or tmdb_id) " + "or both title and year for proper identification" ) return self class UserWatchlistIdentifier(BaseModel): """Watchlist item identifier for removal operations (no notes).""" trakt_id: str | None = Field(default=None, min_length=1, description="Trakt ID") imdb_id: str | None = Field(default=None, min_length=1, description="IMDB ID") tmdb_id: str | None = Field(default=None, min_length=1, description="TMDB ID") title: str | None = Field(default=None, min_length=1, description="Title") year: int | None = Field(default=None, gt=1800, description="Release year") @field_validator("trakt_id", "imdb_id", "tmdb_id", "title", mode="before") @classmethod def _strip_strings(cls, v: object) -> object: return v.strip() if isinstance(v, str) else v @field_validator("trakt_id", mode="after") @classmethod def _validate_trakt_id_numeric(cls, v: str | None) -> str | None: """Ensure trakt_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"trakt_id must be numeric, got: '{v}'") return v @field_validator("tmdb_id", mode="after") @classmethod def _validate_tmdb_id_numeric(cls, v: str | None) -> str | None: """Ensure tmdb_id is numeric if provided.""" if v is not None and not v.isdigit(): raise ValueError(f"tmdb_id must be numeric, got: '{v}'") return v @model_validator(mode="after") def _validate_identifiers(self) -> "UserWatchlistIdentifier": """Ensure at least one identifier (trakt_id/imdb_id/tmdb_id) OR both title+year are provided.""" has_id = any([self.trakt_id, self.imdb_id, self.tmdb_id]) has_title_year = self.title and self.year if not has_id and not has_title_year: raise ValueError( "Watchlist item must include either an identifier (trakt_id, imdb_id, or tmdb_id) " + "or both title and year for proper identification" ) return self class UserWatchlistRequest(BaseModel): """Parameters for adding/removing user watchlist items.""" watchlist_type: Literal["movies", "shows", "seasons", "episodes"] items: list[UserWatchlistRequestItem] = Field( min_length=1, description="List of items to add to watchlist" ) @handle_api_errors_func async def fetch_user_ratings( rating_type: Literal["movies", "shows", "seasons", "episodes"] = "movies", rating: int | None = None, page: int | None = None, ) -> str: """Fetch authenticated user's personal ratings from Trakt. Args: rating_type: Type of ratings to fetch (movies, shows, seasons, episodes) rating: Optional specific rating to filter by (1-10) page: Optional page number for pagination (1-based) Returns: User's personal ratings formatted as markdown. When 'page' is None, only the first page is retrieved (see pagination info in the output). Raises: AuthenticationRequiredError: If user is not authenticated """ logger.debug("fetch_user_ratings called with rating_type=%s", rating_type) # Validate parameters with Pydantic params = UserRatingsParams(rating_type=rating_type, rating=rating, page=page) rating_type, rating, page = params.rating_type, params.rating, params.page try: client = SyncClient() # If 'page' is provided, request that page; otherwise use default page=1. pagination_params = ( PaginationParams(page=page, limit=DEFAULT_LIMIT) if page is not None else None ) paginated_result: PaginatedResponse[ TraktSyncRating ] = await client.get_sync_ratings( rating_type, rating, pagination=pagination_params ) # Handle transitional case where API returns error strings if isinstance(paginated_result, str): error = BaseToolErrorMixin.handle_api_string_error( resource_type=f"user_{rating_type}_ratings", resource_id=f"user_ratings_{rating_type}", error_message=paginated_result, operation="fetch_user_ratings", ) raise error return SyncRatingsFormatters.format_user_ratings( paginated_result, rating_type, rating ) except MCPError: raise @handle_api_errors_func async def add_user_ratings( rating_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: """Add new ratings for the authenticated user. Args: rating_type: Type of content to rate (movies, shows, seasons, episodes) items: List of items to rate with rating and identification info Returns: Summary of added ratings with counts Raises: AuthenticationRequiredError: If user is not authenticated """ logger.debug("add_user_ratings called with rating_type=%s", rating_type) # Validate parameters with Pydantic items_list = [UserRatingRequestItem(**item) for item in items] params = UserRatingRequest(rating_type=rating_type, items=items_list) rating_type, validated_items = params.rating_type, params.items try: client = SyncClient() # Convert to sync request format sync_items: list[TraktSyncRatingItem] = [] for item in validated_items: ids: dict[str, Any] = {} # Add IDs that are provided (upstream validation ensures numeric strings) if item.trakt_id: ids["trakt"] = int(item.trakt_id) if item.imdb_id: ids["imdb"] = item.imdb_id if item.tmdb_id: ids["tmdb"] = int(item.tmdb_id) # Create sync rating item sync_item_data: dict[str, Any] = { "rating": item.rating, "ids": ids, } # Add title and year if provided if item.title: sync_item_data["title"] = item.title if item.year: sync_item_data["year"] = item.year sync_items.append(TraktSyncRatingItem(**sync_item_data)) # Create request with the appropriate type request_data: dict[str, Any] = {rating_type: sync_items} request = TraktSyncRatingsRequest(**request_data) summary: SyncRatingsSummary = await client.add_sync_ratings(request) # Handle transitional case where API returns error strings if isinstance(summary, str): error = BaseToolErrorMixin.handle_api_string_error( resource_type=f"add_user_{rating_type}_ratings", resource_id=f"add_ratings_{rating_type}", error_message=summary, operation="add_user_ratings", ) raise error return SyncRatingsFormatters.format_user_ratings_summary( summary, "added", rating_type ) except MCPError: raise @handle_api_errors_func async def remove_user_ratings( rating_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: """Remove ratings for the authenticated user. Args: rating_type: Type of content to unrate (movies, shows, seasons, episodes) items: List of items to remove ratings from (no rating values needed, just identification) Returns: Summary of removed ratings with counts Raises: AuthenticationRequiredError: If user is not authenticated """ logger.debug("remove_user_ratings called with rating_type=%s", rating_type) # For removal, we only need identifiers (no ratings required) validated_items = [UserRatingIdentifier(**item) for item in items] try: client = SyncClient() # Convert to sync request format (no ratings needed for removal) sync_items: list[TraktSyncRatingItem] = [] for item in validated_items: ids: dict[str, Any] = {} # Add IDs that are provided (upstream validation ensures numeric strings) if item.trakt_id: ids["trakt"] = int(item.trakt_id) if item.imdb_id: ids["imdb"] = item.imdb_id if item.tmdb_id: ids["tmdb"] = int(item.tmdb_id) # Create sync rating item (no rating for removal) sync_item_data: dict[str, Any] = {"ids": ids} # Add title and year if provided if item.title: sync_item_data["title"] = item.title if item.year: sync_item_data["year"] = item.year sync_items.append(TraktSyncRatingItem(**sync_item_data)) # Create request with the appropriate type request_data: dict[str, Any] = {rating_type: sync_items} request = TraktSyncRatingsRequest(**request_data) summary: SyncRatingsSummary = await client.remove_sync_ratings(request) # Handle transitional case where API returns error strings if isinstance(summary, str): error = BaseToolErrorMixin.handle_api_string_error( resource_type=f"remove_user_{rating_type}_ratings", resource_id=f"remove_ratings_{rating_type}", error_message=summary, operation="remove_user_ratings", ) raise error return SyncRatingsFormatters.format_user_ratings_summary( summary, "removed", rating_type ) except MCPError: raise @handle_api_errors_func async def fetch_user_watchlist( watchlist_type: Literal["all", "movies", "shows", "seasons", "episodes"] = "all", sort_by: WatchlistSortField = "rank", sort_how: Literal["asc", "desc"] = "asc", page: int | None = None, ) -> str: """Fetch authenticated user's watchlist from Trakt. Args: watchlist_type: Type of watchlist items to fetch (all, movies, shows, seasons, episodes) sort_by: Field to sort by (rank, added, title, released, runtime, etc.) sort_how: Sort direction (asc, desc) page: Optional page number for pagination (1-based) Returns: User's watchlist formatted as markdown. When 'page' is None, only the first page is retrieved (see pagination info in the output). Raises: AuthenticationRequiredError: If user is not authenticated """ logger.debug("fetch_user_watchlist called with watchlist_type=%s", watchlist_type) # Validate parameters with Pydantic params = UserWatchlistParams( watchlist_type=watchlist_type, sort_by=sort_by, sort_how=sort_how, page=page ) watchlist_type, sort_by, sort_how, page = ( params.watchlist_type, params.sort_by, params.sort_how, params.page, ) try: client = SyncClient() # If 'page' is provided, request that page; otherwise use default page=1. pagination_params = ( PaginationParams(page=page, limit=DEFAULT_LIMIT) if page is not None else None ) paginated_result: PaginatedResponse[ TraktWatchlistItem ] = await client.get_sync_watchlist( watchlist_type, sort_by, sort_how, pagination=pagination_params ) # Handle transitional case where API returns error strings if isinstance(paginated_result, str): error = BaseToolErrorMixin.handle_api_string_error( resource_type=f"user_{watchlist_type}_watchlist", resource_id=f"user_watchlist_{watchlist_type}", error_message=paginated_result, operation="fetch_user_watchlist", ) raise error return SyncWatchlistFormatters.format_user_watchlist( paginated_result, watchlist_type, sort_by, sort_how ) except MCPError: raise @handle_api_errors_func async def add_user_watchlist( watchlist_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: """Add items to the authenticated user's watchlist. Args: watchlist_type: Type of content to add (movies, shows, seasons, episodes) items: List of items to add with identification info and optional notes (VIP only) Returns: Summary of added watchlist items with counts Raises: AuthenticationRequiredError: If user is not authenticated """ logger.debug("add_user_watchlist called with watchlist_type=%s", watchlist_type) # Validate parameters with Pydantic items_list = [UserWatchlistRequestItem(**item) for item in items] params = UserWatchlistRequest(watchlist_type=watchlist_type, items=items_list) watchlist_type, validated_items = params.watchlist_type, params.items try: client = SyncClient() # Convert to sync request format sync_items: list[TraktSyncWatchlistItem] = [] for item in validated_items: ids: dict[str, Any] = {} # Add IDs that are provided (upstream validation ensures numeric strings) if item.trakt_id: ids["trakt"] = int(item.trakt_id) if item.imdb_id: ids["imdb"] = item.imdb_id if item.tmdb_id: ids["tmdb"] = int(item.tmdb_id) # Create sync watchlist item sync_item_data: dict[str, Any] = {"ids": ids} # Add title and year if provided if item.title: sync_item_data["title"] = item.title if item.year: sync_item_data["year"] = item.year # Add notes if provided (VIP only) if item.notes: sync_item_data["notes"] = item.notes sync_items.append(TraktSyncWatchlistItem(**sync_item_data)) # Create request with the appropriate type request_data: dict[str, Any] = {watchlist_type: sync_items} request = TraktSyncWatchlistRequest(**request_data) summary: SyncWatchlistSummary = await client.add_sync_watchlist(request) # Handle transitional case where API returns error strings if isinstance(summary, str): error = BaseToolErrorMixin.handle_api_string_error( resource_type=f"add_user_{watchlist_type}_watchlist", resource_id=f"add_watchlist_{watchlist_type}", error_message=summary, operation="add_user_watchlist", ) raise error return SyncWatchlistFormatters.format_user_watchlist_summary( summary, "added", watchlist_type ) except MCPError: raise @handle_api_errors_func async def remove_user_watchlist( watchlist_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: """Remove items from the authenticated user's watchlist. Args: watchlist_type: Type of content to remove (movies, shows, seasons, episodes) items: List of items to remove from watchlist (no notes needed, just identification) Returns: Summary of removed watchlist items with counts Raises: AuthenticationRequiredError: If user is not authenticated """ logger.debug("remove_user_watchlist called with watchlist_type=%s", watchlist_type) # For removal, we only need identifiers (no notes) validated_items = [UserWatchlistIdentifier(**item) for item in items] try: client = SyncClient() # Convert to sync request format (no notes for removal) sync_items: list[TraktSyncWatchlistItem] = [] for item in validated_items: ids: dict[str, Any] = {} # Add IDs that are provided (upstream validation ensures numeric strings) if item.trakt_id: ids["trakt"] = int(item.trakt_id) if item.imdb_id: ids["imdb"] = item.imdb_id if item.tmdb_id: ids["tmdb"] = int(item.tmdb_id) # Create sync watchlist item (no notes for removal) sync_item_data: dict[str, Any] = {"ids": ids} # Add title and year if provided if item.title: sync_item_data["title"] = item.title if item.year: sync_item_data["year"] = item.year sync_items.append(TraktSyncWatchlistItem(**sync_item_data)) # Create request with the appropriate type request_data: dict[str, Any] = {watchlist_type: sync_items} request = TraktSyncWatchlistRequest(**request_data) summary: SyncWatchlistSummary = await client.remove_sync_watchlist(request) # Handle transitional case where API returns error strings if isinstance(summary, str): error = BaseToolErrorMixin.handle_api_string_error( resource_type=f"remove_user_{watchlist_type}_watchlist", resource_id=f"remove_watchlist_{watchlist_type}", error_message=summary, operation="remove_user_watchlist", ) raise error return SyncWatchlistFormatters.format_user_watchlist_summary( summary, "removed", watchlist_type ) except MCPError: raise def register_sync_tools( mcp: FastMCP, ) -> tuple[ ToolHandler, ToolHandler, ToolHandler, ToolHandler, ToolHandler, ToolHandler ]: """Register sync rating and watchlist tools with the MCP server. Returns: Tuple of tool handlers for type checker visibility """ @mcp.tool( name=SYNC_TOOLS["fetch_user_ratings"], description=( "Fetch the authenticated user's personal ratings from Trakt. " "Supports optional pagination with 'page' parameter. " "Requires OAuth authentication." ), ) async def fetch_user_ratings_tool( rating_type: Literal["movies", "shows", "seasons", "episodes"] = "movies", rating: int | None = None, page: int | None = None, ) -> str: # Validate parameters with Pydantic params = UserRatingsParams(rating_type=rating_type, rating=rating, page=page) return await fetch_user_ratings(params.rating_type, params.rating, params.page) @mcp.tool( name=SYNC_TOOLS["add_user_ratings"], description="Add new ratings for the authenticated user. Requires OAuth authentication.", ) async def add_user_ratings_tool( rating_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: return await add_user_ratings(rating_type, items) @mcp.tool( name=SYNC_TOOLS["remove_user_ratings"], description="Remove ratings for the authenticated user. Requires OAuth authentication.", ) async def remove_user_ratings_tool( rating_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: return await remove_user_ratings(rating_type, items) @mcp.tool( name=SYNC_TOOLS["fetch_user_watchlist"], description=( "Fetch the authenticated user's watchlist from Trakt. " "Supports optional pagination with 'page' parameter and sorting options. " "Requires OAuth authentication." ), ) async def fetch_user_watchlist_tool( watchlist_type: Literal[ "all", "movies", "shows", "seasons", "episodes" ] = "all", sort_by: WatchlistSortField = "rank", sort_how: Literal["asc", "desc"] = "asc", page: int | None = None, ) -> str: # Validate parameters with Pydantic params = UserWatchlistParams( watchlist_type=watchlist_type, sort_by=sort_by, sort_how=sort_how, page=page ) return await fetch_user_watchlist( params.watchlist_type, params.sort_by, params.sort_how, params.page ) @mcp.tool( name=SYNC_TOOLS["add_user_watchlist"], description=( "Add items to the authenticated user's watchlist. " "Supports optional notes (VIP only, 500 character limit). " "Requires OAuth authentication." ), ) async def add_user_watchlist_tool( watchlist_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: return await add_user_watchlist(watchlist_type, items) @mcp.tool( name=SYNC_TOOLS["remove_user_watchlist"], description=( "Remove items from the authenticated user's watchlist. " "Requires OAuth authentication." ), ) async def remove_user_watchlist_tool( watchlist_type: Literal["movies", "shows", "seasons", "episodes"], items: list[dict[str, Any]], ) -> str: return await remove_user_watchlist(watchlist_type, items) # Return handlers for type checker visibility return ( fetch_user_ratings_tool, add_user_ratings_tool, remove_user_ratings_tool, fetch_user_watchlist_tool, add_user_watchlist_tool, remove_user_watchlist_tool, )

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/wwiens/trakt_mcpserver'

If you have feedback or need assistance with the MCP directory API, please join our Discord server