8 Commits

9 changed files with 186 additions and 9 deletions

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "arcaea-offline"
version = "0.2.0"
version = "0.2.1"
authors = [{ name = "283375", email = "log_283375@163.com" }]
description = "Manage your local Arcaea score database."
readme = "README.md"

View File

@ -84,11 +84,15 @@ class AndrealImageGeneratorApiDataConverter:
raise ValueError("No score available.")
best30_avg = self.session.scalar(select(CalculatedPotential.b30))
best30_overflow = (
[self.score(score) for score in scores[30:40]] if len(scores) > 30 else []
)
return {
"content": {
"account_info": self.account_info(),
"best30_avg": best30_avg,
"best30_list": [self.score(score) for score in scores[:30]],
"best30_overflow": [self.score(score) for score in scores[-10:]],
"best30_overflow": best30_overflow,
}
}

View File

@ -1,3 +1,4 @@
from .online import ArcaeaOnlineParser
from .packlist import PacklistParser
from .songlist import SonglistDifficultiesParser, SonglistParser
from .st3 import St3ScoreParser

View File

@ -1,11 +1,41 @@
import contextlib
import json
import math
import time
from os import PathLike
from typing import Any, List, Optional, Union
from sqlalchemy.orm import DeclarativeBase, Session
def fix_timestamp(timestamp: int) -> Union[int, None]:
"""
Some of the `date` column in st3 are strangely truncated. For example,
a `1670283375` may be truncated to `167028`, even `1`. Yes, a single `1`.
To properly handle this situation, we check the timestamp's digits.
If `digits < 5`, we treat this timestamp as a `None`. Otherwise, we try to
fix the timestamp.
:param timestamp: a POSIX timestamp
:return: `None` if the timestamp's digits < 5, otherwise a fixed POSIX timestamp
"""
# find digit length from https://stackoverflow.com/a/2189827/16484891
# CC BY-SA 2.5
# this might give incorrect result when timestamp > 999999999999997,
# see https://stackoverflow.com/a/28883802/16484891 (CC BY-SA 4.0).
# but that's way too later than 9999-12-31 23:59:59, 253402271999,
# I don't think Arcaea would still be an active updated game by then.
# so don't mind those small issues, just use this.
digits = int(math.log10(abs(timestamp))) + 1 if timestamp != 0 else 1
if digits < 5:
return None
timestamp_str = str(timestamp)
current_timestamp_digits = int(math.log10(int(time.time()))) + 1
timestamp_str = timestamp_str.ljust(current_timestamp_digits, "0")
return int(timestamp_str, 10)
def to_db_value(val: Any) -> Any:
if not val:
return None

View File

@ -0,0 +1,75 @@
import json
import logging
from datetime import datetime
from typing import Dict, List, Literal, Optional, TypedDict
from ...models import Score
from .common import ArcaeaParser, fix_timestamp
logger = logging.getLogger(__name__)
class TWebApiRatingMeScoreItem(TypedDict):
song_id: str
difficulty: int
modifier: int
rating: float
score: int
perfect_count: int
near_count: int
miss_count: int
clear_type: int
title: Dict[Literal["ja", "en"], str]
artist: str
time_played: int
bg: str
class TWebApiRatingMeValue(TypedDict):
best_rated_scores: List[TWebApiRatingMeScoreItem]
recent_rated_scores: List[TWebApiRatingMeScoreItem]
class TWebApiRatingMeResult(TypedDict):
success: bool
error_code: Optional[int]
value: Optional[TWebApiRatingMeValue]
class ArcaeaOnlineParser(ArcaeaParser):
def __init__(self, filepath):
super().__init__(filepath)
def parse(self) -> List[Score]:
api_result_root: TWebApiRatingMeResult = json.loads(self.read_file_text())
api_result_value = api_result_root.get("value")
if not api_result_value:
error_code = api_result_root.get("error_code")
raise ValueError(f"Cannot parse API result, error code {error_code}")
best30_score_items = api_result_value.get("best_rated_scores", [])
recent_score_items = api_result_value.get("recent_rated_scores", [])
score_items = best30_score_items + recent_score_items
date_text = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
results: List[Score] = []
for score_item in score_items:
score = Score()
score.song_id = score_item["song_id"]
score.rating_class = score_item["difficulty"]
score.score = score_item["score"]
score.pure = score_item["perfect_count"]
score.far = score_item["near_count"]
score.lost = score_item["miss_count"]
score.date = fix_timestamp(int(score_item["time_played"] / 1000))
score.modifier = score_item["modifier"]
score.clear_type = score_item["clear_type"]
if score.lost == 0:
score.max_recall = score.pure + score.far
score.comment = f"Parsed from web API at {date_text}"
results.append(score)
return results

View File

@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from ...models.scores import Score
from .common import ArcaeaParser
from .common import ArcaeaParser, fix_timestamp
logger = logging.getLogger(__name__)
@ -37,9 +37,6 @@ class St3ScoreParser(ArcaeaParser):
(song_id, rating_class),
).fetchone()[0]
date_str = str(date)
date = None if len(date_str) < 7 else int(date_str.ljust(10, "0"))
items.append(
Score(
song_id=song_id,
@ -48,10 +45,10 @@ class St3ScoreParser(ArcaeaParser):
pure=pure,
far=far,
lost=lost,
date=date,
date=fix_timestamp(date),
modifier=modifier,
clear_type=clear_type,
comment="Imported from st3",
comment=f"Parsed from st3",
)
)

View File

@ -0,0 +1 @@
from .b30_csv import SmartRteB30CsvConverter

View File

@ -0,0 +1,64 @@
from sqlalchemy.orm import Session
from ...models import Chart, ScoreBest
from ...utils.rating import rating_class_to_text
class SmartRteB30CsvConverter:
CSV_ROWS = [
"songname",
"songId",
"Difficulty",
"score",
"Perfect",
"criticalPerfect",
"Far",
"Lost",
"Constant",
"singlePTT",
]
def __init__(
self,
session: Session,
):
self.session = session
def rows(self) -> list:
csv_rows = [self.CSV_ROWS.copy()]
with self.session as session:
results = (
session.query(
Chart.title,
ScoreBest.song_id,
ScoreBest.rating_class,
ScoreBest.score,
ScoreBest.pure,
ScoreBest.shiny_pure,
ScoreBest.far,
ScoreBest.lost,
Chart.constant,
ScoreBest.potential,
)
.join(
Chart,
(Chart.song_id == ScoreBest.song_id)
& (Chart.rating_class == ScoreBest.rating_class),
)
.all()
)
for result in results:
# replace the comma in song title because the target project
# cannot handle quoted string
result = list(result)
result[0] = result[0].replace(",", "")
result[2] = rating_class_to_text(result[2])
# divide constant to float
result[-2] = result[-2] / 10
# round potential
result[-1] = round(result[-1], 5)
csv_rows.append(result)
return csv_rows

View File

@ -80,7 +80,12 @@ class ScoreCalculated(ScoresViewBase):
(
case(
(
(ChartInfo.notes.isnot(None) & ChartInfo.notes != 0),
(
ChartInfo.notes.is_not(None)
& Score.pure.is_not(None)
& Score.far.is_not(None)
& (ChartInfo.notes != 0)
),
Score.score
- func.floor(
(Score.pure * 10000000.0 / ChartInfo.notes)