bemaniutils/bemani/data/api/client.py
tyam 5fc9286ee1
Pop'n Music 27 Unilab support (#94)
* Pop'n Music 27 Unilab support

Known issues:
I don't know how to trigger KAC Lab. This seems to be something that should be able to be accessed on appropriate versions of the dll but I can't seem to figure it out.
Rare softlock on pop'n quest Lively II event if you mess with the phase flags and put the game in an invalid state. In theory (and according to bemaniwiki) the entire event should be clearable on earlier Unilab builds.

Not an issue/will not fix:
狼弦暴威 does not appear in Awakening Elem when the event flag is set. The solution to this (for some reason) is to clear the other 10 events. This is not a bemaniutils issue.
2025-01-21 17:21:54 -05:00

344 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. We also have to be sensitive to any control character
# limitations for memcached.
repr_val = (
"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}"
+ ")"
)
repr_val = repr_val.replace(" ", "_")
repr_val = repr_val.replace("\r", "_")
repr_val = repr_val.replace("\n", "_")
return repr_val
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",
VersionConstants.POPN_MUSIC_UNILAB: "27",
},
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 {}