338 lines
12 KiB
Python
338 lines
12 KiB
Python
import json
|
|
import requests
|
|
from typing import Tuple, Dict, List, Any, Optional
|
|
from typing_extensions import Final
|
|
|
|
from bemani.common import (
|
|
APIConstants,
|
|
GameConstants,
|
|
VersionConstants,
|
|
DBConstants,
|
|
ValidatedDict,
|
|
Time,
|
|
cache,
|
|
)
|
|
|
|
|
|
class APIException(Exception):
|
|
pass
|
|
|
|
|
|
class NotAuthorizedAPIException(APIException):
|
|
pass
|
|
|
|
|
|
class UnsupportedRequestAPIException(APIException):
|
|
pass
|
|
|
|
|
|
class UnrecognizedRequestAPIException(APIException):
|
|
pass
|
|
|
|
|
|
class UnsupportedVersionAPIException(APIException):
|
|
pass
|
|
|
|
|
|
class RemoteServerErrorAPIException(APIException):
|
|
pass
|
|
|
|
|
|
class APIClient:
|
|
"""
|
|
A client that fully speaks BEMAPI and can pull information from a remote server.
|
|
"""
|
|
|
|
API_VERSION: Final[str] = "v1"
|
|
|
|
def __init__(self, base_uri: str, token: str, allow_stats: bool, allow_scores: bool) -> None:
|
|
self.base_uri = base_uri
|
|
self.token = token
|
|
self.allow_stats = allow_stats
|
|
self.allow_scores = allow_scores
|
|
|
|
def __repr__(self) -> str:
|
|
# Specifically defined so that two different instances of the same API client
|
|
# cache under the same key, as we want to share results from a given server
|
|
# to all local requests.
|
|
return (
|
|
"APIClient("
|
|
+ f"base_uri={self.base_uri!r}, "
|
|
+ f"token={self.token!r}, "
|
|
+ f"allow_stats={self.allow_stats!r}, "
|
|
+ f"allow_scores={self.allow_scores!r}"
|
|
+ ")"
|
|
)
|
|
|
|
def _content_type_valid(self, content_type: str) -> bool:
|
|
if ";" in content_type:
|
|
left, right = content_type.split(";", 1)
|
|
left = left.strip().lower()
|
|
right = right.strip().lower()
|
|
|
|
if left == "application/json" and ("=" in right):
|
|
identifier, charset = right.split("=", 1)
|
|
identifier = identifier.strip()
|
|
charset = charset.strip()
|
|
|
|
if identifier == "charset" and charset == "utf-8":
|
|
# This is valid.
|
|
return True
|
|
return False
|
|
|
|
def __exchange_data(self, request_uri: str, request_args: Dict[str, Any]) -> Dict[str, Any]:
|
|
if self.base_uri[-1:] != "/":
|
|
uri = f"{self.base_uri}/{request_uri}"
|
|
else:
|
|
uri = f"{self.base_uri}{request_uri}"
|
|
|
|
headers = {
|
|
"Authorization": f"Token {self.token}",
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
}
|
|
data = json.dumps(request_args).encode("utf8")
|
|
|
|
try:
|
|
r = requests.request(
|
|
"GET",
|
|
uri,
|
|
headers=headers,
|
|
data=data,
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
except Exception:
|
|
raise APIException("Failed to query remote server!")
|
|
|
|
# Verify that content type is in the form of "application/json; charset=utf-8".
|
|
if not self._content_type_valid(r.headers["content-type"]):
|
|
raise APIException(f'API returned invalid content type \'{r.headers["content-type"]}\'!')
|
|
|
|
jsondata = r.json()
|
|
|
|
if r.status_code == 200:
|
|
return jsondata
|
|
|
|
if "error" not in jsondata:
|
|
raise APIException(
|
|
f"API returned error code {r.status_code} but did not include 'error' attribute in response JSON!"
|
|
)
|
|
error = jsondata["error"]
|
|
|
|
if r.status_code == 401:
|
|
raise NotAuthorizedAPIException("The API token used is not authorized against this server!")
|
|
if r.status_code == 404:
|
|
raise UnsupportedRequestAPIException("The server does not support this game/version or request object!")
|
|
if r.status_code == 405:
|
|
raise UnrecognizedRequestAPIException("The server did not recognize the request!")
|
|
if r.status_code == 500:
|
|
raise RemoteServerErrorAPIException(
|
|
f"The server had an error processing the request and returned '{error}'"
|
|
)
|
|
if r.status_code == 501:
|
|
raise UnsupportedVersionAPIException("The server does not support this version of the API!")
|
|
raise APIException("The server returned an invalid status code {}!", format(r.status_code))
|
|
|
|
def __translate(self, game: GameConstants, version: int) -> Tuple[str, str]:
|
|
servergame = {
|
|
GameConstants.DDR: "ddr",
|
|
GameConstants.IIDX: "iidx",
|
|
GameConstants.JUBEAT: "jubeat",
|
|
GameConstants.MUSECA: "museca",
|
|
GameConstants.POPN_MUSIC: "popnmusic",
|
|
GameConstants.REFLEC_BEAT: "reflecbeat",
|
|
GameConstants.SDVX: "soundvoltex",
|
|
}.get(game)
|
|
if servergame is None:
|
|
raise UnsupportedRequestAPIException("The client does not support this game/version!")
|
|
|
|
if version >= DBConstants.OMNIMIX_VERSION_BUMP:
|
|
version = version - DBConstants.OMNIMIX_VERSION_BUMP
|
|
omnimix = True
|
|
else:
|
|
omnimix = False
|
|
|
|
serverversion = (
|
|
{
|
|
GameConstants.DDR: {
|
|
VersionConstants.DDR_X2: "12",
|
|
VersionConstants.DDR_X3_VS_2NDMIX: "13",
|
|
VersionConstants.DDR_2013: "14",
|
|
VersionConstants.DDR_2014: "15",
|
|
VersionConstants.DDR_ACE: "16",
|
|
VersionConstants.DDR_A20: "17",
|
|
},
|
|
GameConstants.IIDX: {
|
|
VersionConstants.IIDX_TRICORO: "20",
|
|
VersionConstants.IIDX_SPADA: "21",
|
|
VersionConstants.IIDX_PENDUAL: "22",
|
|
VersionConstants.IIDX_COPULA: "23",
|
|
VersionConstants.IIDX_SINOBUZ: "24",
|
|
VersionConstants.IIDX_CANNON_BALLERS: "25",
|
|
VersionConstants.IIDX_ROOTAGE: "26",
|
|
VersionConstants.IIDX_HEROIC_VERSE: "27",
|
|
VersionConstants.IIDX_BISTROVER: "28",
|
|
},
|
|
GameConstants.JUBEAT: {
|
|
VersionConstants.JUBEAT_SAUCER: "5",
|
|
VersionConstants.JUBEAT_SAUCER_FULFILL: "5a",
|
|
VersionConstants.JUBEAT_PROP: "6",
|
|
VersionConstants.JUBEAT_QUBELL: "7",
|
|
VersionConstants.JUBEAT_CLAN: "8",
|
|
VersionConstants.JUBEAT_FESTO: "9",
|
|
VersionConstants.JUBEAT_AVENUE: "10",
|
|
},
|
|
GameConstants.MUSECA: {
|
|
VersionConstants.MUSECA: "1",
|
|
VersionConstants.MUSECA_1_PLUS: "1p",
|
|
},
|
|
GameConstants.POPN_MUSIC: {
|
|
VersionConstants.POPN_MUSIC_TUNE_STREET: "19",
|
|
VersionConstants.POPN_MUSIC_FANTASIA: "20",
|
|
VersionConstants.POPN_MUSIC_SUNNY_PARK: "21",
|
|
VersionConstants.POPN_MUSIC_LAPISTORIA: "22",
|
|
VersionConstants.POPN_MUSIC_ECLALE: "23",
|
|
VersionConstants.POPN_MUSIC_USANEKO: "24",
|
|
VersionConstants.POPN_MUSIC_PEACE: "25",
|
|
VersionConstants.POPN_MUSIC_KAIMEI_RIDDLES: "26",
|
|
},
|
|
GameConstants.REFLEC_BEAT: {
|
|
VersionConstants.REFLEC_BEAT: "1",
|
|
VersionConstants.REFLEC_BEAT_LIMELIGHT: "2",
|
|
VersionConstants.REFLEC_BEAT_COLETTE: "3as",
|
|
VersionConstants.REFLEC_BEAT_GROOVIN: "4u",
|
|
VersionConstants.REFLEC_BEAT_VOLZZA: "5",
|
|
VersionConstants.REFLEC_BEAT_VOLZZA_2: "5a",
|
|
VersionConstants.REFLEC_BEAT_REFLESIA: "6",
|
|
},
|
|
GameConstants.SDVX: {
|
|
VersionConstants.SDVX_BOOTH: "1",
|
|
VersionConstants.SDVX_INFINITE_INFECTION: "2",
|
|
VersionConstants.SDVX_GRAVITY_WARS: "3",
|
|
VersionConstants.SDVX_HEAVENLY_HAVEN: "4",
|
|
},
|
|
}
|
|
.get(game, {})
|
|
.get(version)
|
|
)
|
|
if serverversion is None:
|
|
raise UnsupportedRequestAPIException("The client does not support this game/version!")
|
|
|
|
if omnimix:
|
|
serverversion = "o" + serverversion
|
|
|
|
return (servergame, serverversion)
|
|
|
|
# Not caching this, as it is only hit when looking at the admin panel, and we want this to
|
|
# always be up-to-date.
|
|
def get_server_info(self) -> ValidatedDict:
|
|
resp = self.__exchange_data("", {})
|
|
return ValidatedDict(
|
|
{
|
|
"name": resp["name"],
|
|
"email": resp["email"],
|
|
"versions": resp["versions"],
|
|
}
|
|
)
|
|
|
|
# Not caching this, as we would have to go back and ensure that any code which got outdated
|
|
# profiles from a cache didn't end up with KeyError exceptions when trying to link profiles to
|
|
# records. This is the coward's way out, but whatever.
|
|
def get_profiles(
|
|
self, game: GameConstants, version: int, idtype: APIConstants, ids: List[str]
|
|
) -> List[Dict[str, Any]]:
|
|
# Allow remote servers to be disabled
|
|
if not self.allow_scores:
|
|
return []
|
|
|
|
try:
|
|
servergame, serverversion = self.__translate(game, version)
|
|
resp = self.__exchange_data(
|
|
f"{self.API_VERSION}/{servergame}/{serverversion}",
|
|
{
|
|
"ids": ids,
|
|
"type": idtype.value,
|
|
"objects": ["profile"],
|
|
},
|
|
)
|
|
return resp["profile"]
|
|
except APIException:
|
|
# Couldn't talk to server, assume empty profiles
|
|
return []
|
|
|
|
@cache.memoize(Time.SECONDS_IN_MINUTE * 1)
|
|
def get_records(
|
|
self,
|
|
game: GameConstants,
|
|
version: int,
|
|
idtype: APIConstants,
|
|
ids: List[str],
|
|
since: Optional[int] = None,
|
|
until: Optional[int] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
# Allow remote servers to be disabled
|
|
if not self.allow_scores:
|
|
return []
|
|
|
|
try:
|
|
servergame, serverversion = self.__translate(game, version)
|
|
data: Dict[str, Any] = {
|
|
"ids": ids,
|
|
"type": idtype.value,
|
|
"objects": ["records"],
|
|
}
|
|
if since is not None:
|
|
data["since"] = since
|
|
if until is not None:
|
|
data["until"] = until
|
|
resp = self.__exchange_data(
|
|
f"{self.API_VERSION}/{servergame}/{serverversion}",
|
|
data,
|
|
)
|
|
return resp["records"]
|
|
except APIException:
|
|
# Couldn't talk to server, assume empty records
|
|
return []
|
|
|
|
@cache.memoize(Time.SECONDS_IN_MINUTE * 5)
|
|
def get_statistics(
|
|
self, game: GameConstants, version: int, idtype: APIConstants, ids: List[str]
|
|
) -> List[Dict[str, Any]]:
|
|
# Allow remote servers to be disabled
|
|
if not self.allow_stats:
|
|
return []
|
|
|
|
try:
|
|
servergame, serverversion = self.__translate(game, version)
|
|
resp = self.__exchange_data(
|
|
f"{self.API_VERSION}/{servergame}/{serverversion}",
|
|
{
|
|
"ids": ids,
|
|
"type": idtype.value,
|
|
"objects": ["statistics"],
|
|
},
|
|
)
|
|
return resp["statistics"]
|
|
except APIException:
|
|
# Couldn't talk to server, assume empty statistics
|
|
return []
|
|
|
|
@cache.memoize(Time.SECONDS_IN_HOUR * 1)
|
|
def get_catalog(self, game: GameConstants, version: int) -> Dict[str, List[Dict[str, Any]]]:
|
|
# No point disallowing this, since its only ever used for bootstrapping.
|
|
|
|
try:
|
|
servergame, serverversion = self.__translate(game, version)
|
|
resp = self.__exchange_data(
|
|
f"{self.API_VERSION}/{servergame}/{serverversion}",
|
|
{
|
|
"ids": [],
|
|
"type": "server",
|
|
"objects": ["catalog"],
|
|
},
|
|
)
|
|
return resp["catalog"]
|
|
except APIException:
|
|
# Couldn't talk to server, assume empty catalog
|
|
return {}
|