""" VNDB API Client Handles all interactions with the VNDB API """ import httpx import json from typing import Dict, List, Any, Optional from enum import Enum class VndbEndpoint(Enum): """VNDB API Endpoints""" SCHEMA = "/schema" STATS = "/stats" USER = "/user" AUTHINFO = "/authinfo" VN = "/vn" RELEASE = "/release" CHARACTER = "/character" STAFF = "/staff" PRODUCER = "/producer" TAG = "/tag" TRAIT = "/trait" QUOTE = "/quote" ULIST = "/ulist" RLIST = "/rlist" ULIST_LABELS = "/ulist_labels" class VndbClient: """Client for interacting with VNDB API""" BASE_URL = "https://api.vndb.org/kana" SANDBOX_URL = "https://beta.vndb.org/api/kana" def __init__(self, token: Optional[str] = None, use_sandbox: bool = False): """ Initialize VNDB client Args: token: Optional API token for authenticated requests use_sandbox: Whether to use sandbox endpoint """ self.token = token self.base_url = self.SANDBOX_URL if use_sandbox else self.BASE_URL self.headers = { "Content-Type": "application/json", } if token: self.headers["Authorization"] = f"Token {token}" async def _request( self, endpoint: str, method: str = "GET", data: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Make HTTP request to VNDB API Args: endpoint: API endpoint method: HTTP method (GET, POST, PATCH, DELETE) data: Request body data Returns: Response JSON Raises: httpx.HTTPError: On HTTP errors json.JSONDecodeError: On invalid JSON response """ url = f"{self.base_url}{endpoint}" async with httpx.AsyncClient(timeout=10) as client: response = await client.request( method, url, headers=self.headers, content=json.dumps(data) if data else None, ) response.raise_for_status() return response.json() # Simple Requests async def get_schema(self) -> Dict[str, Any]: """Get API schema with metadata""" return await self._request(VndbEndpoint.SCHEMA.value) async def get_stats(self) -> Dict[str, Any]: """Get database statistics""" return await self._request(VndbEndpoint.STATS.value) async def get_user(self, queries: List[str], fields: Optional[List[str]] = None) -> Dict[str, Any]: """ Lookup users by ID or username Args: queries: List of user IDs or usernames to lookup fields: List of fields to retrieve (lengthvotes, lengthvotes_sum) Returns: User information """ params = {"q": queries} if fields: params["fields"] = fields # Build query string query_parts = [f"q={q}" for q in queries] if fields: query_parts.append(f"fields={','.join(fields)}") query_string = "&".join(query_parts) url = f"{self.base_url}{VndbEndpoint.USER.value}?{query_string}" async with httpx.AsyncClient(timeout=10) as client: response = await client.get(url, headers=self.headers) response.raise_for_status() return response.json() async def get_authinfo(self) -> Dict[str, Any]: """Get authenticated user information""" if not self.token: raise ValueError("Token required for authinfo") return await self._request(VndbEndpoint.AUTHINFO.value) # Database Querying async def query_vn( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, user: Optional[str] = None, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """ Query visual novels Args: filters: Filter conditions fields: Fields to retrieve sort: Sort field (id, title, released, rating, votecount, searchrank) reverse: Sort in descending order results: Number of results per page (max 100) page: Page number starting from 1 count: Include total count user: User ID for user-specific filters compact_filters: Include compact filter representation normalized_filters: Include normalized filter representation Returns: Query results """ data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } if user: data["user"] = user return await self._request(VndbEndpoint.VN.value, "POST", data) async def query_release( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, user: Optional[str] = None, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """Query releases""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } if user: data["user"] = user return await self._request(VndbEndpoint.RELEASE.value, "POST", data) async def query_character( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """Query characters""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } return await self._request(VndbEndpoint.CHARACTER.value, "POST", data) async def query_staff( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """Query staff""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } return await self._request(VndbEndpoint.STAFF.value, "POST", data) async def query_producer( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """Query producers""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } return await self._request(VndbEndpoint.PRODUCER.value, "POST", data) async def query_tag( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """Query tags""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } return await self._request(VndbEndpoint.TAG.value, "POST", data) async def query_trait( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """Query traits""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } return await self._request(VndbEndpoint.TRAIT.value, "POST", data) async def query_quote( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, count: bool = False, compact_filters: bool = False, normalized_filters: bool = False, ) -> Dict[str, Any]: """Query quotes""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, "compact_filters": compact_filters, "normalized_filters": normalized_filters, } return await self._request(VndbEndpoint.QUOTE.value, "POST", data) # List Management async def query_ulist( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, user: Optional[str] = None, count: bool = False, ) -> Dict[str, Any]: """Query user visual novel list""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, } if user: data["user"] = user return await self._request(VndbEndpoint.ULIST.value, "POST", data) async def query_rlist( self, filters: Optional[List[Any]] = None, fields: Optional[List[str]] = None, sort: str = "id", reverse: bool = False, results: int = 10, page: int = 1, user: Optional[str] = None, count: bool = False, ) -> Dict[str, Any]: """Query user release list""" data = { "filters": filters or [], "fields": ",".join(fields) if fields else "", "sort": sort, "reverse": reverse, "results": results, "page": page, "count": count, } if user: data["user"] = user return await self._request(VndbEndpoint.RLIST.value, "POST", data) async def get_ulist_labels(self, user: Optional[str] = None) -> Dict[str, Any]: """Get user list labels""" url = f"{self.base_url}{VndbEndpoint.ULIST_LABELS.value}" if user: url += f"?user={user}" async with httpx.AsyncClient(timeout=10) as client: response = await client.get(url, headers=self.headers) response.raise_for_status() return response.json() async def add_to_ulist( self, vn_id: str, status: Optional[str] = None, notes: Optional[str] = None, labels: Optional[List[str]] = None, voted: Optional[int] = None, ) -> Dict[str, Any]: """Add or update visual novel in user list""" if not self.token: raise ValueError("Token required for list operations") data = {"id": vn_id} if status: data["status"] = status if notes: data["notes"] = notes if labels: data["labels"] = labels if voted is not None: data["voted"] = voted return await self._request( f"{VndbEndpoint.ULIST.value}/{vn_id}", "PATCH", data ) async def add_to_rlist( self, release_id: str, status: Optional[str] = None, notes: Optional[str] = None, ) -> Dict[str, Any]: """Add or update release in user list""" if not self.token: raise ValueError("Token required for list operations") data = {"id": release_id} if status: data["status"] = status if notes: data["notes"] = notes return await self._request( f"{VndbEndpoint.RLIST.value}/{release_id}", "PATCH", data ) async def remove_from_ulist(self, vn_id: str) -> None: """Remove visual novel from user list""" if not self.token: raise ValueError("Token required for list operations") await self._request(f"{VndbEndpoint.ULIST.value}/{vn_id}", "DELETE") async def remove_from_rlist(self, release_id: str) -> None: """Remove release from user list""" if not self.token: raise ValueError("Token required for list operations") await self._request(f"{VndbEndpoint.RLIST.value}/{release_id}", "DELETE")