1137 lines
45 KiB
Python
1137 lines
45 KiB
Python
from typing import List, Optional, Dict, Any, Tuple, Set
|
|
|
|
from bemani.common import APIConstants, GameConstants, VersionConstants, DBConstants, Parallel
|
|
from bemani.data.interfaces import APIProviderInterface
|
|
from bemani.data.api.base import BaseGlobalData
|
|
from bemani.data.mysql.user import UserData
|
|
from bemani.data.mysql.music import MusicData
|
|
from bemani.data.remoteuser import RemoteUser
|
|
from bemani.data.types import UserID, Score, Song
|
|
|
|
|
|
class GlobalMusicData(BaseGlobalData):
|
|
|
|
def __init__(self, api: APIProviderInterface, user: UserData, music: MusicData) -> None:
|
|
super().__init__(api)
|
|
self.user = user
|
|
self.music = music
|
|
|
|
def __get_cardids(self, userid: UserID) -> List[str]:
|
|
if RemoteUser.is_remote(userid):
|
|
return [RemoteUser.userid_to_card(userid)]
|
|
else:
|
|
return self.user.get_cards(userid)
|
|
|
|
def __min(self, int1: int, int2: int) -> int:
|
|
# -1 is used as a 'no value' so it should not overwrite a 0
|
|
if int1 == -1:
|
|
return int2
|
|
if int2 == -1:
|
|
return int1
|
|
return min(int1, int2)
|
|
|
|
def __max(self, int1: int, int2: int) -> int:
|
|
return max(int1, int2)
|
|
|
|
def __format_ddr_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
|
|
halo = {
|
|
'none': DBConstants.DDR_HALO_NONE,
|
|
'gfc': DBConstants.DDR_HALO_GOOD_FULL_COMBO,
|
|
'fc': DBConstants.DDR_HALO_GREAT_FULL_COMBO,
|
|
'pfc': DBConstants.DDR_HALO_PERFECT_FULL_COMBO,
|
|
'mfc': DBConstants.DDR_HALO_MARVELOUS_FULL_COMBO,
|
|
}.get(data.get('halo'), DBConstants.DDR_HALO_NONE)
|
|
rank = {
|
|
"AAA": DBConstants.DDR_RANK_AAA,
|
|
"AA+": DBConstants.DDR_RANK_AA_PLUS,
|
|
"AA": DBConstants.DDR_RANK_AA,
|
|
"AA-": DBConstants.DDR_RANK_AA_MINUS,
|
|
"A+": DBConstants.DDR_RANK_A_PLUS,
|
|
"A": DBConstants.DDR_RANK_A,
|
|
"A-": DBConstants.DDR_RANK_A_MINUS,
|
|
"B+": DBConstants.DDR_RANK_B_PLUS,
|
|
"B": DBConstants.DDR_RANK_B,
|
|
"B-": DBConstants.DDR_RANK_B_MINUS,
|
|
"C+": DBConstants.DDR_RANK_C_PLUS,
|
|
"C": DBConstants.DDR_RANK_C,
|
|
"C-": DBConstants.DDR_RANK_C_MINUS,
|
|
"D+": DBConstants.DDR_RANK_D_PLUS,
|
|
"D": DBConstants.DDR_RANK_D,
|
|
"E": DBConstants.DDR_RANK_E,
|
|
}.get(data.get('rank'), DBConstants.DDR_RANK_E)
|
|
|
|
ghost = ''
|
|
trace: List[int] = []
|
|
|
|
if version == VersionConstants.DDR_ACE:
|
|
# DDR Ace is specia
|
|
ghost = ''.join([str(x) for x in data.get('ghost', [])])
|
|
else:
|
|
trace = [int(x) for x in data.get('ghost', [])]
|
|
|
|
return Score(
|
|
-1,
|
|
songid,
|
|
songchart,
|
|
int(data.get('points', 0)),
|
|
int(data.get('timestamp', -1)),
|
|
self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))),
|
|
-1, # No location for remote play
|
|
1, # No play info for remote play
|
|
{
|
|
'combo': int(data.get('combo', -1)),
|
|
'rank': rank,
|
|
'halo': halo,
|
|
'ghost': ghost,
|
|
'trace': trace,
|
|
},
|
|
)
|
|
|
|
def __format_iidx_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
|
|
status = {
|
|
'np': DBConstants.IIDX_CLEAR_STATUS_NO_PLAY,
|
|
'failed': DBConstants.IIDX_CLEAR_STATUS_FAILED,
|
|
'ac': DBConstants.IIDX_CLEAR_STATUS_ASSIST_CLEAR,
|
|
'ec': DBConstants.IIDX_CLEAR_STATUS_EASY_CLEAR,
|
|
'nc': DBConstants.IIDX_CLEAR_STATUS_CLEAR,
|
|
'hc': DBConstants.IIDX_CLEAR_STATUS_HARD_CLEAR,
|
|
'exhc': DBConstants.IIDX_CLEAR_STATUS_EX_HARD_CLEAR,
|
|
'fc': DBConstants.IIDX_CLEAR_STATUS_FULL_COMBO,
|
|
}.get(data.get('status'), DBConstants.IIDX_CLEAR_STATUS_NO_PLAY)
|
|
|
|
return Score(
|
|
-1,
|
|
songid,
|
|
songchart,
|
|
int(data.get('points', 0)),
|
|
int(data.get('timestamp', -1)),
|
|
self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))),
|
|
-1, # No location for remote play
|
|
1, # No play info for remote play
|
|
{
|
|
'clear_status': status,
|
|
'ghost': bytes([int(b) for b in data.get('ghost', [])]),
|
|
'miss_count': int(data.get('miss', -1)),
|
|
'pgreats': int(data.get('pgreat', -1)),
|
|
'greats': int(data.get('great', -1)),
|
|
},
|
|
)
|
|
|
|
def __format_jubeat_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
|
|
status = {
|
|
'failed': DBConstants.JUBEAT_PLAY_MEDAL_FAILED,
|
|
'cleared': DBConstants.JUBEAT_PLAY_MEDAL_CLEARED,
|
|
'nfc': DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_FULL_COMBO,
|
|
'fc': DBConstants.JUBEAT_PLAY_MEDAL_FULL_COMBO,
|
|
'nec': DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT,
|
|
'exc': DBConstants.JUBEAT_PLAY_MEDAL_EXCELLENT,
|
|
}.get(data.get('status'), DBConstants.JUBEAT_PLAY_MEDAL_FAILED)
|
|
|
|
return Score(
|
|
-1,
|
|
songid,
|
|
songchart,
|
|
int(data.get('points', 0)),
|
|
int(data.get('timestamp', -1)),
|
|
self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))),
|
|
-1, # No location for remote play
|
|
1, # No play info for remote play
|
|
{
|
|
'medal': status,
|
|
'combo': int(data.get('combo', -1)),
|
|
'ghost': [int(x) for x in data.get('ghost', [])],
|
|
'music_rate': int(data.get('music_rate')),
|
|
},
|
|
)
|
|
|
|
def __format_museca_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
|
|
rank = {
|
|
'death': DBConstants.MUSECA_GRADE_DEATH,
|
|
'poor': DBConstants.MUSECA_GRADE_POOR,
|
|
'mediocre': DBConstants.MUSECA_GRADE_MEDIOCRE,
|
|
'good': DBConstants.MUSECA_GRADE_GOOD,
|
|
'great': DBConstants.MUSECA_GRADE_GREAT,
|
|
'excellent': DBConstants.MUSECA_GRADE_EXCELLENT,
|
|
'superb': DBConstants.MUSECA_GRADE_SUPERB,
|
|
'masterpiece': DBConstants.MUSECA_GRADE_MASTERPIECE,
|
|
'perfect': DBConstants.MUSECA_GRADE_PERFECT,
|
|
}.get(data.get('rank'), DBConstants.MUSECA_GRADE_DEATH)
|
|
status = {
|
|
'failed': DBConstants.MUSECA_CLEAR_TYPE_FAILED,
|
|
'cleared': DBConstants.MUSECA_CLEAR_TYPE_CLEARED,
|
|
'fc': DBConstants.MUSECA_CLEAR_TYPE_FULL_COMBO,
|
|
}.get(data.get('status'), DBConstants.MUSECA_CLEAR_TYPE_FAILED)
|
|
|
|
return Score(
|
|
-1,
|
|
songid,
|
|
songchart,
|
|
int(data.get('points', 0)),
|
|
int(data.get('timestamp', -1)),
|
|
self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))),
|
|
-1, # No location for remote play
|
|
1, # No play info for remote play
|
|
{
|
|
'grade': rank,
|
|
'clear_type': status,
|
|
'combo': int(data.get('combo', -1)),
|
|
'stats': {
|
|
'btn_rate': int(data.get('buttonrate', -1)),
|
|
'long_rate': int(data.get('longrate', -1)),
|
|
'vol_rate': int(data.get('volrate', -1)),
|
|
},
|
|
},
|
|
)
|
|
|
|
def __format_popn_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
|
|
status = {
|
|
'cf': DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED,
|
|
'df': DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED,
|
|
'sf': DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED,
|
|
'ec': DBConstants.POPN_MUSIC_PLAY_MEDAL_EASY_CLEAR,
|
|
'cc': DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_CLEARED,
|
|
'dc': DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_CLEARED,
|
|
'sc': DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_CLEARED,
|
|
'cfc': DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO,
|
|
'dfc': DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO,
|
|
'sfc': DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO,
|
|
'p': DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT,
|
|
}.get(data.get('status'), DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED)
|
|
|
|
return Score(
|
|
-1,
|
|
songid,
|
|
songchart,
|
|
int(data.get('points', 0)),
|
|
int(data.get('timestamp', -1)),
|
|
self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))),
|
|
-1, # No location for remote play
|
|
1, # No play info for remote play
|
|
{
|
|
'medal': status,
|
|
'combo': int(data.get('combo', -1)),
|
|
},
|
|
)
|
|
|
|
def __format_reflec_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
|
|
status = {
|
|
'np': DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY,
|
|
'failed': DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED,
|
|
'cleared': DBConstants.REFLEC_BEAT_CLEAR_TYPE_CLEARED,
|
|
'hc': DBConstants.REFLEC_BEAT_CLEAR_TYPE_HARD_CLEARED,
|
|
'shc': DBConstants.REFLEC_BEAT_CLEAR_TYPE_S_HARD_CLEARED,
|
|
}.get(data.get('status'), DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY)
|
|
halo = {
|
|
'none': DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE,
|
|
'ac': DBConstants.REFLEC_BEAT_COMBO_TYPE_ALMOST_COMBO,
|
|
'fc': DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO,
|
|
'fcaj': DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST,
|
|
}.get(data.get('halo'), DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE)
|
|
|
|
return Score(
|
|
-1,
|
|
songid,
|
|
songchart,
|
|
int(data.get('points', 0)),
|
|
int(data.get('timestamp', -1)),
|
|
self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))),
|
|
-1, # No location for remote play
|
|
1, # No play info for remote play
|
|
{
|
|
'achievement_rate': int(data.get('rate', -1)),
|
|
'clear_type': status,
|
|
'combo_type': halo,
|
|
'miss_count': int(data.get('miss', -1)),
|
|
'combo': int(data.get('combo', -1)),
|
|
},
|
|
)
|
|
|
|
def __format_sdvx_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score:
|
|
status = {
|
|
'np': DBConstants.SDVX_CLEAR_TYPE_NO_PLAY,
|
|
'failed': DBConstants.SDVX_CLEAR_TYPE_FAILED,
|
|
'cleared': DBConstants.SDVX_CLEAR_TYPE_CLEAR,
|
|
'hc': DBConstants.SDVX_CLEAR_TYPE_HARD_CLEAR,
|
|
'uc': DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN,
|
|
'puc': DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN,
|
|
}.get(data.get('status'), DBConstants.SDVX_CLEAR_TYPE_NO_PLAY)
|
|
rank = {
|
|
'E': DBConstants.SDVX_GRADE_NO_PLAY,
|
|
'D': DBConstants.SDVX_GRADE_D,
|
|
'C': DBConstants.SDVX_GRADE_C,
|
|
'B': DBConstants.SDVX_GRADE_B,
|
|
'A': DBConstants.SDVX_GRADE_A,
|
|
'A+': DBConstants.SDVX_GRADE_A_PLUS,
|
|
'AA': DBConstants.SDVX_GRADE_AA,
|
|
'AA+': DBConstants.SDVX_GRADE_AA_PLUS,
|
|
'AAA': DBConstants.SDVX_GRADE_AAA,
|
|
'AAA+': DBConstants.SDVX_GRADE_AAA_PLUS,
|
|
'S': DBConstants.SDVX_GRADE_S,
|
|
}.get(data.get('rank'), DBConstants.SDVX_GRADE_NO_PLAY)
|
|
|
|
return Score(
|
|
-1,
|
|
songid,
|
|
songchart,
|
|
int(data.get('points', 0)),
|
|
int(data.get('timestamp', -1)),
|
|
self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))),
|
|
-1, # No location for remote play
|
|
1, # No play info for remote play
|
|
{
|
|
'grade': rank,
|
|
'clear_type': status,
|
|
'combo': int(data.get('combo', -1)),
|
|
'stats': {
|
|
'btn_rate': int(data.get('buttonrate', -1)),
|
|
'long_rate': int(data.get('longrate', -1)),
|
|
'vol_rate': int(data.get('volrate', -1)),
|
|
},
|
|
},
|
|
)
|
|
|
|
def __format_score(self, game: GameConstants, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Optional[Score]:
|
|
if game == GameConstants.DDR:
|
|
return self.__format_ddr_score(version, songid, songchart, data)
|
|
if game == GameConstants.IIDX:
|
|
return self.__format_iidx_score(version, songid, songchart, data)
|
|
if game == GameConstants.JUBEAT:
|
|
return self.__format_jubeat_score(version, songid, songchart, data)
|
|
if game == GameConstants.MUSECA:
|
|
return self.__format_museca_score(version, songid, songchart, data)
|
|
if game == GameConstants.POPN_MUSIC:
|
|
return self.__format_popn_score(version, songid, songchart, data)
|
|
if game == GameConstants.REFLEC_BEAT:
|
|
return self.__format_reflec_score(version, songid, songchart, data)
|
|
if game == GameConstants.SDVX:
|
|
return self.__format_sdvx_score(version, songid, songchart, data)
|
|
return None
|
|
|
|
def __merge_ddr_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
return Score(
|
|
-1,
|
|
oldscore.id,
|
|
oldscore.chart,
|
|
self.__max(oldscore.points, newscore.points),
|
|
self.__max(oldscore.timestamp, newscore.timestamp),
|
|
self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)),
|
|
oldscore.location, # Always propagate location from local setup if possible
|
|
oldscore.plays + newscore.plays,
|
|
{
|
|
'rank': self.__max(oldscore.data['rank'], newscore.data['rank']),
|
|
'halo': self.__max(oldscore.data['halo'], newscore.data['halo']),
|
|
'ghost': oldscore.data.get('ghost') if oldscore.points > newscore.points else newscore.data.get('ghost'),
|
|
'trace': oldscore.data.get('trace') if oldscore.points > newscore.points else newscore.data.get('trace'),
|
|
'combo': self.__max(oldscore.data['combo'], newscore.data['combo']),
|
|
},
|
|
)
|
|
|
|
def __merge_iidx_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
return Score(
|
|
-1,
|
|
oldscore.id,
|
|
oldscore.chart,
|
|
self.__max(oldscore.points, newscore.points),
|
|
self.__max(oldscore.timestamp, newscore.timestamp),
|
|
self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)),
|
|
oldscore.location, # Always propagate location from local setup if possible
|
|
oldscore.plays + newscore.plays,
|
|
{
|
|
'clear_status': self.__max(oldscore.data['clear_status'], newscore.data['clear_status']),
|
|
'ghost': oldscore.data.get('ghost') if oldscore.points > newscore.points else newscore.data.get('ghost'),
|
|
'miss_count': self.__min(oldscore.data.get_int('miss_count', -1), newscore.data.get_int('miss_count', -1)),
|
|
'pgreats': oldscore.data.get_int('pgreats', -1) if oldscore.points > newscore.points else newscore.data.get_int('pgreats', -1),
|
|
'greats': oldscore.data.get_int('greats', -1) if oldscore.points > newscore.points else newscore.data.get_int('greats', -1),
|
|
},
|
|
)
|
|
|
|
def __merge_jubeat_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
return Score(
|
|
-1,
|
|
oldscore.id,
|
|
oldscore.chart,
|
|
self.__max(oldscore.points, newscore.points),
|
|
self.__max(oldscore.timestamp, newscore.timestamp),
|
|
self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)),
|
|
oldscore.location, # Always propagate location from local setup if possible
|
|
oldscore.plays + newscore.plays,
|
|
{
|
|
'ghost': oldscore.data.get('ghost') if oldscore.points > newscore.points else newscore.data.get('ghost'),
|
|
'combo': self.__max(oldscore.data['combo'], newscore.data['combo']),
|
|
'medal': self.__max(oldscore.data['medal'], newscore.data['medal']),
|
|
'music_rate': self.__max(oldscore.data['music_rate'], newscore.data['music_rate']),
|
|
},
|
|
)
|
|
|
|
def __merge_museca_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
return Score(
|
|
-1,
|
|
oldscore.id,
|
|
oldscore.chart,
|
|
self.__max(oldscore.points, newscore.points),
|
|
self.__max(oldscore.timestamp, newscore.timestamp),
|
|
self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)),
|
|
oldscore.location, # Always propagate location from local setup if possible
|
|
oldscore.plays + newscore.plays,
|
|
{
|
|
'grade': self.__max(oldscore.data['grade'], newscore.data['grade']),
|
|
'clear_type': self.__max(oldscore.data['clear_type'], newscore.data['clear_type']),
|
|
'combo': self.__max(oldscore.data['combo'], newscore.data['combo']),
|
|
'stats': oldscore.data['stats'] if oldscore.points > newscore.points else newscore.data['stats'],
|
|
},
|
|
)
|
|
|
|
def __merge_popn_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
return Score(
|
|
-1,
|
|
oldscore.id,
|
|
oldscore.chart,
|
|
self.__max(oldscore.points, newscore.points),
|
|
self.__max(oldscore.timestamp, newscore.timestamp),
|
|
self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)),
|
|
oldscore.location, # Always propagate location from local setup if possible
|
|
oldscore.plays + newscore.plays,
|
|
{
|
|
'combo': self.__max(oldscore.data['combo'], newscore.data['combo']),
|
|
'medal': self.__max(oldscore.data['medal'], newscore.data['medal']),
|
|
},
|
|
)
|
|
|
|
def __merge_reflec_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
return Score(
|
|
-1,
|
|
oldscore.id,
|
|
oldscore.chart,
|
|
self.__max(oldscore.points, newscore.points),
|
|
self.__max(oldscore.timestamp, newscore.timestamp),
|
|
self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)),
|
|
oldscore.location, # Always propagate location from local setup if possible
|
|
oldscore.plays + newscore.plays,
|
|
{
|
|
'clear_type': self.__max(oldscore.data['clear_type'], newscore.data['clear_type']),
|
|
'combo_type': self.__max(oldscore.data['combo_type'], newscore.data['combo_type']),
|
|
'miss_count': self.__min(oldscore.data.get_int('miss_count', -1), newscore.data.get_int('miss_count', -1)),
|
|
'combo': self.__max(oldscore.data['combo'], newscore.data['combo']),
|
|
'achievement_rate': self.__max(oldscore.data['achievement_rate'], newscore.data['achievement_rate']),
|
|
},
|
|
)
|
|
|
|
def __merge_sdvx_score(self, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
return Score(
|
|
-1,
|
|
oldscore.id,
|
|
oldscore.chart,
|
|
self.__max(oldscore.points, newscore.points),
|
|
self.__max(oldscore.timestamp, newscore.timestamp),
|
|
self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)),
|
|
oldscore.location, # Always propagate location from local setup if possible
|
|
oldscore.plays + newscore.plays,
|
|
{
|
|
'grade': self.__max(oldscore.data['grade'], newscore.data['grade']),
|
|
'clear_type': self.__max(oldscore.data['clear_type'], newscore.data['clear_type']),
|
|
'combo': self.__max(oldscore.data.get_int('combo', 1), newscore.data.get_int('combo', -1)),
|
|
'stats': oldscore.data['stats'] if oldscore.points > newscore.points else newscore.data['stats'],
|
|
},
|
|
)
|
|
|
|
def __merge_score(self, game: GameConstants, version: int, oldscore: Score, newscore: Score) -> Score:
|
|
if oldscore.id != newscore.id or oldscore.chart != newscore.chart:
|
|
raise Exception('Logic error! Tried to merge scores from different song/charts!')
|
|
|
|
if game == GameConstants.DDR:
|
|
return self.__merge_ddr_score(version, oldscore, newscore)
|
|
if game == GameConstants.IIDX:
|
|
return self.__merge_iidx_score(version, oldscore, newscore)
|
|
if game == GameConstants.JUBEAT:
|
|
return self.__merge_jubeat_score(version, oldscore, newscore)
|
|
if game == GameConstants.MUSECA:
|
|
return self.__merge_museca_score(version, oldscore, newscore)
|
|
if game == GameConstants.POPN_MUSIC:
|
|
return self.__merge_popn_score(version, oldscore, newscore)
|
|
if game == GameConstants.REFLEC_BEAT:
|
|
return self.__merge_reflec_score(version, oldscore, newscore)
|
|
if game == GameConstants.SDVX:
|
|
return self.__merge_sdvx_score(version, oldscore, newscore)
|
|
|
|
return oldscore
|
|
|
|
def get_score(self, game: GameConstants, version: int, userid: UserID, songid: int, songchart: int) -> Optional[Score]:
|
|
# Helper function so we can iterate over all servers for a single card
|
|
def get_scores_for_card(cardid: str) -> List[Score]:
|
|
return Parallel.flatten(Parallel.call(
|
|
[client.get_records for client in self.clients],
|
|
game,
|
|
version,
|
|
APIConstants.ID_TYPE_INSTANCE,
|
|
[songid, songchart, cardid],
|
|
))
|
|
|
|
relevant_cards = self.__get_cardids(userid)
|
|
if RemoteUser.is_remote(userid):
|
|
# No need to look up local score for this user
|
|
scores = Parallel.flatten(Parallel.map(
|
|
get_scores_for_card,
|
|
relevant_cards,
|
|
))
|
|
localscore = None
|
|
else:
|
|
localscore, scores = Parallel.execute([
|
|
lambda: self.music.get_score(game, version, userid, songid, songchart),
|
|
lambda: Parallel.flatten(Parallel.map(
|
|
get_scores_for_card,
|
|
relevant_cards,
|
|
)),
|
|
])
|
|
|
|
topscore = localscore
|
|
|
|
for score in scores:
|
|
if int(score['song']) != songid:
|
|
continue
|
|
if int(score['chart']) != songchart:
|
|
continue
|
|
|
|
newscore = self.__format_score(game, version, songid, songchart, score)
|
|
|
|
if topscore is None:
|
|
# No merging needed
|
|
topscore = newscore
|
|
continue
|
|
|
|
topscore = self.__merge_score(game, version, topscore, newscore)
|
|
|
|
return topscore
|
|
|
|
def get_scores(
|
|
self,
|
|
game: GameConstants,
|
|
version: int,
|
|
userid: UserID,
|
|
since: Optional[int]=None,
|
|
until: Optional[int]=None,
|
|
) -> List[Score]:
|
|
relevant_cards = self.__get_cardids(userid)
|
|
if RemoteUser.is_remote(userid):
|
|
# No need to look up local score for this user
|
|
scores = Parallel.flatten(Parallel.call(
|
|
[client.get_records for client in self.clients],
|
|
game,
|
|
version,
|
|
APIConstants.ID_TYPE_CARD,
|
|
relevant_cards,
|
|
since,
|
|
until,
|
|
))
|
|
localscores: List[Score] = []
|
|
else:
|
|
localscores, scores = Parallel.execute([
|
|
lambda: self.music.get_scores(game, version, userid, since, until),
|
|
lambda: Parallel.flatten(Parallel.call(
|
|
[client.get_records for client in self.clients],
|
|
game,
|
|
version,
|
|
APIConstants.ID_TYPE_CARD,
|
|
relevant_cards,
|
|
since,
|
|
until,
|
|
)),
|
|
])
|
|
|
|
allscores: Dict[int, Dict[int, Score]] = {}
|
|
|
|
def add_score(score: Score) -> None:
|
|
if score.id not in allscores:
|
|
allscores[score.id] = {}
|
|
allscores[score.id][score.chart] = score
|
|
|
|
def get_score(songid: int, songchart: int) -> Optional[Score]:
|
|
return allscores.get(songid, {}).get(songchart)
|
|
|
|
# First, seed with local scores
|
|
for score in localscores:
|
|
add_score(score)
|
|
|
|
# Second, merge in remote scorse
|
|
for remotescore in scores:
|
|
songid = int(remotescore['song'])
|
|
chart = int(remotescore['chart'])
|
|
newscore = self.__format_score(game, version, songid, chart, remotescore)
|
|
oldscore = get_score(songid, chart)
|
|
|
|
if oldscore is None:
|
|
add_score(newscore)
|
|
else:
|
|
add_score(self.__merge_score(game, version, oldscore, newscore))
|
|
|
|
# Finally, flatten and return
|
|
finalscores: List[Score] = []
|
|
for songid in allscores:
|
|
for chart in allscores[songid]:
|
|
finalscores.append(allscores[songid][chart])
|
|
|
|
return finalscores
|
|
|
|
def __merge_global_scores(
|
|
self,
|
|
game: GameConstants,
|
|
version: int,
|
|
localcards: List[Tuple[str, UserID]],
|
|
localscores: List[Tuple[UserID, Score]],
|
|
remotescores: List[Dict[str, Any]],
|
|
) -> List[Tuple[UserID, Score]]:
|
|
card_to_id = {cardid: userid for (cardid, userid) in localcards}
|
|
allscores: Dict[UserID, Dict[int, Dict[int, Score]]] = {}
|
|
|
|
def add_score(userid: UserID, score: Score) -> None:
|
|
if userid not in allscores:
|
|
allscores[userid] = {}
|
|
if score.id not in allscores[userid]:
|
|
allscores[userid][score.id] = {}
|
|
allscores[userid][score.id][score.chart] = score
|
|
|
|
def get_score(userid: UserID, songid: int, songchart: int) -> Optional[Score]:
|
|
return allscores.get(userid, {}).get(songid, {}).get(songchart)
|
|
|
|
# First, seed with local scores
|
|
for (userid, score) in localscores:
|
|
add_score(userid, score)
|
|
|
|
# Second, merge in remote scorse
|
|
for remotescore in remotescores:
|
|
# Figure out the userid of this score
|
|
cardids = sorted([card.upper() for card in remotescore.get('cards', [])])
|
|
if len(cardids) == 0:
|
|
continue
|
|
|
|
for cardid in cardids:
|
|
if cardid in card_to_id:
|
|
userid = card_to_id[cardid]
|
|
break
|
|
else:
|
|
userid = RemoteUser.card_to_userid(cardids[0])
|
|
|
|
songid = int(remotescore['song'])
|
|
chart = int(remotescore['chart'])
|
|
newscore = self.__format_score(game, version, songid, chart, remotescore)
|
|
oldscore = get_score(userid, songid, chart)
|
|
|
|
if oldscore is None:
|
|
add_score(userid, newscore)
|
|
else:
|
|
add_score(userid, self.__merge_score(game, version, oldscore, newscore))
|
|
|
|
# Finally, flatten and return
|
|
finalscores: List[Tuple[UserID, Score]] = []
|
|
for userid in allscores:
|
|
for songid in allscores[userid]:
|
|
for chart in allscores[userid][songid]:
|
|
finalscores.append((userid, allscores[userid][songid][chart]))
|
|
|
|
return finalscores
|
|
|
|
def get_all_scores(
|
|
self,
|
|
game: GameConstants,
|
|
version: Optional[int]=None,
|
|
userid: Optional[UserID]=None,
|
|
songid: Optional[int]=None,
|
|
songchart: Optional[int]=None,
|
|
since: Optional[int]=None,
|
|
until: Optional[int]=None,
|
|
) -> List[Tuple[UserID, Score]]:
|
|
# First, pass off to local-only if this was called with parameters we don't support
|
|
if (
|
|
version is None or
|
|
userid is not None or
|
|
songid is None
|
|
):
|
|
return self.music.get_all_scores(game, version, userid, songid, songchart, since, until)
|
|
|
|
# Now, figure out the request key based on passed in parameters
|
|
if songchart is None:
|
|
songkey = [songid]
|
|
else:
|
|
songkey = [songid, songchart]
|
|
|
|
# Now, fetch all the scores remotely and locally
|
|
localcards, localscores, remotescores = Parallel.execute([
|
|
self.user.get_all_cards,
|
|
lambda: self.music.get_all_scores(game, version, userid, songid, songchart, since, until),
|
|
lambda: Parallel.flatten(Parallel.call(
|
|
[client.get_records for client in self.clients],
|
|
game,
|
|
version,
|
|
APIConstants.ID_TYPE_SONG,
|
|
songkey,
|
|
since,
|
|
until,
|
|
)),
|
|
])
|
|
|
|
return self.__merge_global_scores(game, version, localcards, localscores, remotescores)
|
|
|
|
def __merge_global_records(
|
|
self,
|
|
game: GameConstants,
|
|
version: int,
|
|
localcards: List[Tuple[str, UserID]],
|
|
localscores: List[Tuple[UserID, Score]],
|
|
remotescores: List[Dict[str, Any]],
|
|
) -> List[Tuple[UserID, Score]]:
|
|
card_to_id = {cardid: userid for (cardid, userid) in localcards}
|
|
allscores: Dict[int, Dict[int, Tuple[UserID, Score]]] = {}
|
|
|
|
def add_score(userid: UserID, score: Score) -> None:
|
|
if score.id not in allscores:
|
|
allscores[score.id] = {}
|
|
allscores[score.id][score.chart] = (userid, score)
|
|
|
|
def get_score(songid: int, songchart: int) -> Tuple[Optional[UserID], Optional[Score]]:
|
|
return allscores.get(songid, {}).get(songchart, (None, None))
|
|
|
|
# First, seed with local records
|
|
for (userid, score) in localscores:
|
|
add_score(userid, score)
|
|
|
|
# Second, merge in remote records
|
|
for remotescore in remotescores:
|
|
# Figure out the userid of this score
|
|
cardids = sorted([card.upper() for card in remotescore.get('cards', [])])
|
|
if len(cardids) == 0:
|
|
continue
|
|
|
|
for cardid in cardids:
|
|
if cardid in card_to_id:
|
|
userid = card_to_id[cardid]
|
|
break
|
|
else:
|
|
userid = RemoteUser.card_to_userid(cardids[0])
|
|
|
|
songid = int(remotescore['song'])
|
|
chart = int(remotescore['chart'])
|
|
newscore = self.__format_score(game, version, songid, chart, remotescore)
|
|
oldid, oldscore = get_score(songid, chart)
|
|
|
|
if oldscore is None:
|
|
add_score(userid, newscore)
|
|
else:
|
|
# if IDs are the same then we should merge them
|
|
if oldid == userid:
|
|
add_score(userid, self.__merge_score(game, version, oldscore, newscore))
|
|
else:
|
|
# if the IDs are different we need to check which score actually belongs
|
|
if newscore.points > oldscore.points:
|
|
add_score(userid, newscore)
|
|
|
|
# Finally, flatten and return
|
|
finalscores: List[Tuple[UserID, Score]] = []
|
|
for songid in allscores:
|
|
for chart in allscores[songid]:
|
|
finalscores.append((allscores[songid][chart][0], allscores[songid][chart][1]))
|
|
|
|
return finalscores
|
|
|
|
def get_all_records(
|
|
self,
|
|
game: GameConstants,
|
|
version: Optional[int]=None,
|
|
userlist: Optional[List[UserID]]=None,
|
|
locationlist: Optional[List[int]]=None,
|
|
) -> List[Tuple[UserID, Score]]:
|
|
# First, pass off to local-only if this was called with parameters we don't support
|
|
if (
|
|
version is None or
|
|
userlist is not None or
|
|
locationlist is not None
|
|
):
|
|
return self.music.get_all_records(game, version, userlist, locationlist)
|
|
|
|
# Now, fetch all records remotely and locally
|
|
localcards, localscores, remotescores = Parallel.execute([
|
|
self.user.get_all_cards,
|
|
lambda: self.music.get_all_records(game, version, userlist, locationlist),
|
|
lambda: Parallel.flatten(Parallel.call(
|
|
[client.get_records for client in self.clients],
|
|
game,
|
|
version,
|
|
APIConstants.ID_TYPE_SERVER,
|
|
[],
|
|
)),
|
|
])
|
|
|
|
return self.__merge_global_records(game, version, localcards, localscores, remotescores)
|
|
|
|
def get_clear_rates(
|
|
self,
|
|
game: GameConstants,
|
|
version: int,
|
|
songid: Optional[int]=None,
|
|
songchart: Optional[int]=None,
|
|
) -> Dict[int, Dict[int, Dict[str, int]]]:
|
|
"""
|
|
Given an optional songid, or optional songid and songchart, looks up clear rates
|
|
in remote servers that are connected to us. If neither id or chart is given, looks
|
|
up global clear rates. If songid is given, looks up clear rates for each chart for
|
|
the song. If songid and chart is given, looks up clear rates for that song/chart.
|
|
|
|
Returns a dictionary keyed by songid, whos values are a dictionary keyed by chart,
|
|
whos values are a dictionary containing integer counts keyed by 'plays', 'clears',
|
|
and 'combos'. An example is as follows:
|
|
|
|
{
|
|
musicid: {
|
|
chart: {
|
|
plays: total plays,
|
|
clears: total clears,
|
|
combos: total full combos,
|
|
},
|
|
},
|
|
}
|
|
"""
|
|
|
|
if songid is None and songchart is None:
|
|
statistics = Parallel.flatten(Parallel.call(
|
|
[client.get_statistics for client in self.clients],
|
|
game,
|
|
version,
|
|
APIConstants.ID_TYPE_SERVER,
|
|
[],
|
|
))
|
|
elif songid is not None:
|
|
if songchart is None:
|
|
ids = [songid]
|
|
else:
|
|
ids = [songid, songchart]
|
|
statistics = Parallel.flatten(Parallel.call(
|
|
[client.get_statistics for client in self.clients],
|
|
game,
|
|
version,
|
|
APIConstants.ID_TYPE_SONG,
|
|
ids,
|
|
))
|
|
else:
|
|
statistics = []
|
|
|
|
retval: Dict[int, Dict[int, Dict[str, int]]] = {}
|
|
for stat in statistics:
|
|
songid = stat.get('song')
|
|
songchart = stat.get('chart')
|
|
|
|
if songid is None or songchart is None:
|
|
continue
|
|
songid = int(songid)
|
|
songchart = int(songchart)
|
|
|
|
if songid not in retval:
|
|
retval[songid] = {}
|
|
if songchart not in retval[songid]:
|
|
retval[songid][songchart] = {
|
|
'plays': 0,
|
|
'clears': 0,
|
|
'combos': 0,
|
|
}
|
|
|
|
def get_val(v: str) -> int:
|
|
out = stat.get(v, -1)
|
|
if out < 0:
|
|
out = 0
|
|
return out
|
|
|
|
retval[songid][songchart]['plays'] += get_val('plays')
|
|
retval[songid][songchart]['clears'] += get_val('clears')
|
|
retval[songid][songchart]['combos'] += get_val('combos')
|
|
|
|
return retval
|
|
|
|
def __format_ddr_song(
|
|
self,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Song:
|
|
return Song(
|
|
game=GameConstants.DDR,
|
|
version=version,
|
|
songid=songid,
|
|
songchart=songchart,
|
|
name=name,
|
|
artist=artist,
|
|
genre=genre,
|
|
data={
|
|
'groove': {
|
|
'air': int(data['groove']['air']),
|
|
'chaos': int(data['groove']['chaos']),
|
|
'freeze': int(data['groove']['freeze']),
|
|
'stream': int(data['groove']['stream']),
|
|
'voltage': int(data['groove']['voltage']),
|
|
},
|
|
'bpm_min': int(data['bpm_min']),
|
|
'bpm_max': int(data['bpm_max']),
|
|
'category': int(data['category']),
|
|
'difficulty': int(data['difficulty']),
|
|
'edit_id': int(data['editid']),
|
|
},
|
|
)
|
|
|
|
def __format_iidx_song(
|
|
self,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Song:
|
|
return Song(
|
|
game=GameConstants.IIDX,
|
|
version=version,
|
|
songid=songid,
|
|
songchart=songchart,
|
|
name=name,
|
|
artist=artist,
|
|
genre=genre,
|
|
data={
|
|
'bpm_min': int(data['bpm_min']),
|
|
'bpm_max': int(data['bpm_max']),
|
|
'notecount': int(data['notecount']),
|
|
'difficulty': int(data['difficulty']),
|
|
},
|
|
)
|
|
|
|
def __format_jubeat_song(
|
|
self,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Song:
|
|
defaultcategory = {
|
|
1: VersionConstants.JUBEAT,
|
|
2: VersionConstants.JUBEAT_RIPPLES,
|
|
3: VersionConstants.JUBEAT_KNIT,
|
|
4: VersionConstants.JUBEAT_COPIOUS,
|
|
5: VersionConstants.JUBEAT_SAUCER,
|
|
6: VersionConstants.JUBEAT_PROP,
|
|
7: VersionConstants.JUBEAT_QUBELL,
|
|
8: VersionConstants.JUBEAT_CLAN,
|
|
9: VersionConstants.JUBEAT_FESTO
|
|
}.get(int(songid / 10000000), VersionConstants.JUBEAT)
|
|
# Map the category to the version numbers defined on BEMAPI.
|
|
categorymapping = {
|
|
'1': VersionConstants.JUBEAT,
|
|
'2': VersionConstants.JUBEAT_RIPPLES,
|
|
'2a': VersionConstants.JUBEAT_RIPPLES_APPEND,
|
|
'3': VersionConstants.JUBEAT_KNIT,
|
|
'3a': VersionConstants.JUBEAT_KNIT_APPEND,
|
|
'4': VersionConstants.JUBEAT_COPIOUS,
|
|
'4a': VersionConstants.JUBEAT_COPIOUS_APPEND,
|
|
'5': VersionConstants.JUBEAT_SAUCER,
|
|
'5a': VersionConstants.JUBEAT_SAUCER_FULFILL,
|
|
'6': VersionConstants.JUBEAT_PROP,
|
|
'7': VersionConstants.JUBEAT_QUBELL,
|
|
'8': VersionConstants.JUBEAT_CLAN,
|
|
'9': VersionConstants.JUBEAT_FESTO,
|
|
}
|
|
return Song(
|
|
game=GameConstants.JUBEAT,
|
|
version=version,
|
|
songid=songid,
|
|
songchart=songchart,
|
|
name=name,
|
|
artist=artist,
|
|
genre=genre,
|
|
data={
|
|
'bpm_min': int(data['bpm_min']),
|
|
'bpm_max': int(data['bpm_max']),
|
|
'difficulty': int(data['difficulty']),
|
|
'version': categorymapping.get(data.get('category', '0'), defaultcategory),
|
|
},
|
|
)
|
|
|
|
def __format_museca_song(
|
|
self,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Song:
|
|
return Song(
|
|
game=GameConstants.MUSECA,
|
|
version=version,
|
|
songid=songid,
|
|
songchart=songchart,
|
|
name=name,
|
|
artist=artist,
|
|
genre=genre,
|
|
data={
|
|
'bpm_min': int(data['bpm_min']),
|
|
'bpm_max': int(data['bpm_max']),
|
|
'limited': int(data['limited']),
|
|
'difficulty': int(data['difficulty']),
|
|
},
|
|
)
|
|
|
|
def __format_popn_song(
|
|
self,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Song:
|
|
return Song(
|
|
game=GameConstants.POPN_MUSIC,
|
|
version=version,
|
|
songid=songid,
|
|
songchart=songchart,
|
|
name=name,
|
|
artist=artist,
|
|
genre=genre,
|
|
data={
|
|
'difficulty': int(data['difficulty']),
|
|
'category': str(data['category']),
|
|
},
|
|
)
|
|
|
|
def __format_reflec_song(
|
|
self,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Song:
|
|
return Song(
|
|
game=GameConstants.REFLEC_BEAT,
|
|
version=version,
|
|
songid=songid,
|
|
songchart=songchart,
|
|
name=name,
|
|
artist=artist,
|
|
genre=genre,
|
|
data={
|
|
'difficulty': int(data['difficulty']),
|
|
'folder': int(data['category']),
|
|
'chart_id': str(data['musicid']),
|
|
},
|
|
)
|
|
|
|
def __format_sdvx_song(
|
|
self,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Song:
|
|
return Song(
|
|
game=GameConstants.SDVX,
|
|
version=version,
|
|
songid=songid,
|
|
songchart=songchart,
|
|
name=name,
|
|
artist=artist,
|
|
genre=genre,
|
|
data={
|
|
'bpm_min': int(data['bpm_min']),
|
|
'bpm_max': int(data['bpm_max']),
|
|
'limited': int(data['limited']),
|
|
'difficulty': int(data['difficulty']),
|
|
},
|
|
)
|
|
|
|
def __format_song(
|
|
self,
|
|
game: GameConstants,
|
|
version: int,
|
|
songid: int,
|
|
songchart: int,
|
|
name: Optional[str],
|
|
artist: Optional[str],
|
|
genre: Optional[str],
|
|
data: Dict[str, Any],
|
|
) -> Optional[Song]:
|
|
if game == GameConstants.DDR:
|
|
return self.__format_ddr_song(version, songid, songchart, name, artist, genre, data)
|
|
if game == GameConstants.IIDX:
|
|
return self.__format_iidx_song(version, songid, songchart, name, artist, genre, data)
|
|
if game == GameConstants.JUBEAT:
|
|
return self.__format_jubeat_song(version, songid, songchart, name, artist, genre, data)
|
|
if game == GameConstants.MUSECA:
|
|
return self.__format_museca_song(version, songid, songchart, name, artist, genre, data)
|
|
if game == GameConstants.POPN_MUSIC:
|
|
return self.__format_popn_song(version, songid, songchart, name, artist, genre, data)
|
|
if game == GameConstants.REFLEC_BEAT:
|
|
return self.__format_reflec_song(version, songid, songchart, name, artist, genre, data)
|
|
if game == GameConstants.SDVX:
|
|
return self.__format_sdvx_song(version, songid, songchart, name, artist, genre, data)
|
|
return None
|
|
|
|
def get_all_songs(
|
|
self,
|
|
game: GameConstants,
|
|
version: Optional[int]=None,
|
|
) -> List[Song]:
|
|
"""
|
|
Given a game and a version, look up all song/chart combos associated with that game.
|
|
|
|
Parameters:
|
|
game - Enum value representing a game series.
|
|
version - Integer representing which version of the game.
|
|
|
|
Returns:
|
|
A list of Song objects detailing the song information for each song.
|
|
"""
|
|
if version is None:
|
|
# We could do a ton of work to support this by iterating over all versions
|
|
# and combining, but this isn't going to be used in that manner, so lets
|
|
# skip that for now.
|
|
return []
|
|
|
|
catalogs: List[Dict[str, List[Dict[str, Any]]]] = Parallel.call(
|
|
[client.get_catalog for client in self.clients],
|
|
game,
|
|
version
|
|
)
|
|
retval: List[Song] = []
|
|
seen: Set[str] = set()
|
|
for catalog in catalogs:
|
|
for entry in catalog.get('songs', []):
|
|
song = self.__format_song(
|
|
game,
|
|
version,
|
|
int(entry['song']),
|
|
int(entry['chart']),
|
|
str(entry['title'] if entry['title'] is not None else "") or None,
|
|
str(entry['artist'] if entry['artist'] is not None else "") or None,
|
|
str(entry['genre'] if entry['genre'] is not None else "") or None,
|
|
entry,
|
|
)
|
|
if song is None:
|
|
continue
|
|
|
|
key = f"{song.id}_{song.chart}"
|
|
if key in seen:
|
|
continue
|
|
|
|
retval.append(song)
|
|
seen.add(key)
|
|
return retval
|