1
0
mirror of synced 2024-12-15 15:51:15 +01:00
bemaniutils/bemani/data/api/client.py

289 lines
10 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
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 _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',
},
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',
},
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',
},
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',
},
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)
def get_server_info(self) -> ValidatedDict:
resp = self.__exchange_data('', {})
return ValidatedDict({
'name': resp['name'],
'email': resp['email'],
'versions': resp['versions'],
})
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 []
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 []
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 []
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 {}