15 Commits

Author SHA1 Message Date
2b8b13ca95 fix: adapt to new model and tests
- I actually forgot I wrote tests lol
2025-05-31 18:12:58 +08:00
743bbe209f fix(db): DifficultyLocalization.artist 2025-05-31 18:12:00 +08:00
a680a6fd7d feat(db): v4 to v5 migration 2025-05-31 15:36:28 +08:00
ebb649aef6 impr(db): v1 to v4 migration 2025-05-31 15:35:55 +08:00
9d7054d29a wip(db): v5 models
- Literally reverting f19ac4d8d5
2025-05-31 14:34:45 +08:00
4ea49ebeda feat(db): v1 to v4 migration 2025-05-31 10:31:50 +08:00
113e022967 feat(db): alembic init 2025-05-31 10:26:41 +08:00
0fd7d3aa5e chore: pre-commit hooks 2025-05-31 10:25:13 +08:00
8e9c61829d feat: ArcaeaSongSide.LEPHON 2024-11-23 20:25:26 +08:00
d143632025 fix: typing issues 2024-10-02 12:59:55 +08:00
6e8ac3dee7 refactor!: remove v4 database 2024-10-02 12:51:22 +08:00
779fe0130e ci: add type checker 2024-10-02 12:44:12 +08:00
5ca9a5aaa3 chore: bump version 0.3.0a0.dev0 2024-10-02 00:46:24 +08:00
2377d233b1 chore: code reformatting 2024-10-01 22:57:19 +08:00
3b9609ee82 fix!: ruff lint errors
* Refactor KanaeDayNight enum
2024-10-01 22:51:40 +08:00
60 changed files with 1617 additions and 1705 deletions

View File

@ -3,7 +3,7 @@ name: test & lint
on:
push:
branches:
- '*'
- "*"
pull_request:
types: [opened, reopened]
workflow_dispatch:
@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
fail-fast: false
steps:
@ -23,9 +23,9 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
run: 'pip install .[dev]'
run: "pip install .[dev]"
- name: Run tests
run: 'python -m pytest -v'
run: "python -m pytest -v"
ruff:
runs-on: ubuntu-latest
@ -33,9 +33,22 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: "3.12"
- name: Install dev dependencies
run: 'pip install .[dev]'
run: "pip install .[dev]"
- name: Run linter
run: 'ruff check'
run: "ruff check"
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dev dependencies
run: "pip install .[dev]"
- name: Run pyright
uses: jakebailey/pyright-action@v2

View File

@ -6,7 +6,7 @@ repos:
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8
rev: v0.11.12
hooks:
- id: ruff
args: ["--fix"]

View File

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

View File

@ -0,0 +1 @@
DATABASE_VERSION = 5

View File

@ -5,6 +5,13 @@ from ._common import StepBooster
class LegacyMapStepBooster(StepBooster):
__fragment_boost_multipliers = {
None: Decimal("1.0"),
100: Decimal("1.1"),
250: Decimal("1.25"),
500: Decimal("1.5"),
}
def __init__(
self,
stamina: Literal[2, 4, 6],
@ -35,11 +42,5 @@ class LegacyMapStepBooster(StepBooster):
def final_value(self) -> Decimal:
stamina_multiplier = Decimal(self.stamina)
fragments_multiplier = Decimal(1)
if self.fragments == 100:
fragments_multiplier = Decimal("1.1")
elif self.fragments == 250:
fragments_multiplier = Decimal("1.25")
elif self.fragments == 500:
fragments_multiplier = Decimal("1.5")
fragments_multiplier = self.__fragment_boost_multipliers[self.fragments]
return stamina_multiplier * fragments_multiplier

View File

@ -52,6 +52,4 @@ class WorldMainMapCalculators:
play_rating_sqrt = (
Decimal(50) * step - Decimal("2.5") * partner_step_value
) / (Decimal("2.45") * partner_step_value)
return (
play_rating_sqrt**2 if play_rating_sqrt >= 0 else -(play_rating_sqrt**2)
)
return play_rating_sqrt**2 if play_rating_sqrt >= 0 else -(play_rating_sqrt**2)

View File

@ -13,6 +13,7 @@ class ArcaeaSongSide(IntEnum):
LIGHT = 0
CONFLICT = 1
COLORLESS = 2
LEPHON = 3
class ArcaeaPlayResultModifier(IntEnum):

View File

@ -1,3 +0,0 @@
from .db import Database
__all__ = ["Database"]

View File

@ -1,404 +0,0 @@
import logging
import math
from typing import Iterable, Optional, Type, Union
from sqlalchemy import Engine, func, inspect, select
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
from arcaea_offline.singleton import Singleton
from .models.v4.config import ConfigBase, Property
from .models.v4.scores import (
CalculatedPotential,
Score,
ScoreBest,
ScoreCalculated,
ScoresBase,
ScoresViewBase,
)
from .models.v4.songs import (
Chart,
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongsBase,
SongsViewBase,
)
logger = logging.getLogger(__name__)
class Database(metaclass=Singleton):
def __init__(self, engine: Optional[Engine]):
try:
self.__engine
except AttributeError:
self.__engine = None
if engine is None:
if isinstance(self.engine, Engine):
return
raise ValueError("No sqlalchemy.Engine instance specified before.")
if not isinstance(engine, Engine):
raise ValueError(
f"A sqlalchemy.Engine instance expected, not {repr(engine)}"
)
if isinstance(self.engine, Engine):
logger.warning(
"A sqlalchemy.Engine instance %r has been specified "
"and will be replaced to %r",
self.engine,
engine,
)
self.engine = engine
@property
def engine(self) -> Engine:
return self.__engine # type: ignore
@engine.setter
def engine(self, value: Engine):
if not isinstance(value, Engine):
raise ValueError("Database.engine only accepts sqlalchemy.Engine")
self.__engine = value
self.__sessionmaker = sessionmaker(self.__engine)
@property
def sessionmaker(self):
return self.__sessionmaker
# region init
def init(self, checkfirst: bool = True):
# create tables & views
if checkfirst:
# > https://github.com/kvesteri/sqlalchemy-utils/issues/396
# > view.create_view() causes DuplicateTableError on
# > Base.metadata.create_all(checkfirst=True)
# so if `checkfirst` is True, drop these views before creating
SongsViewBase.metadata.drop_all(self.engine)
ScoresViewBase.metadata.drop_all(self.engine)
SongsBase.metadata.create_all(self.engine, checkfirst=checkfirst)
SongsViewBase.metadata.create_all(self.engine)
ScoresBase.metadata.create_all(self.engine, checkfirst=checkfirst)
ScoresViewBase.metadata.create_all(self.engine)
ConfigBase.metadata.create_all(self.engine, checkfirst=checkfirst)
# insert version property
with self.sessionmaker() as session:
stmt = select(Property.value).where(Property.key == "version")
result = session.execute(stmt).fetchone()
if not checkfirst or not result:
session.add(Property(key="version", value="4"))
session.commit()
def check_init(self) -> bool:
# check table exists
expect_tables = (
list(SongsBase.metadata.tables.keys())
+ list(ScoresBase.metadata.tables.keys())
+ list(ConfigBase.metadata.tables.keys())
+ [
Chart.__tablename__,
ScoreCalculated.__tablename__,
ScoreBest.__tablename__,
CalculatedPotential.__tablename__,
]
)
return all(inspect(self.engine).has_table(t) for t in expect_tables)
# endregion
def version(self) -> Union[int, None]:
stmt = select(Property).where(Property.key == "version")
with self.sessionmaker() as session:
result = session.scalar(stmt)
return None if result is None else int(result.value)
# region Pack
def get_packs(self):
stmt = select(Pack)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_pack(self, pack_id: str):
stmt = select(Pack).where(Pack.id == pack_id)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
def get_pack_localized(self, pack_id: str):
stmt = select(PackLocalized).where(PackLocalized.id == pack_id)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
# endregion
# region Song
def get_songs(self):
stmt = select(Song)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_songs_by_pack_id(self, pack_id: str):
stmt = select(Song).where(Song.set == pack_id)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_song(self, song_id: str):
stmt = select(Song).where(Song.id == song_id)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
def get_song_localized(self, song_id: str):
stmt = select(SongLocalized).where(SongLocalized.id == song_id)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
# endregion
# region Difficulty
def get_difficulties(self):
stmt = select(Difficulty)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_difficulties_by_song_id(self, song_id: str):
stmt = select(Difficulty).where(Difficulty.song_id == song_id)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_difficulties_localized_by_song_id(self, song_id: str):
stmt = select(DifficultyLocalized).where(DifficultyLocalized.song_id == song_id)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_difficulty(self, song_id: str, rating_class: int):
stmt = select(Difficulty).where(
(Difficulty.song_id == song_id) & (Difficulty.rating_class == rating_class)
)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
def get_difficulty_localized(self, song_id: str, rating_class: int):
stmt = select(DifficultyLocalized).where(
(DifficultyLocalized.song_id == song_id)
& (DifficultyLocalized.rating_class == rating_class)
)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
# endregion
# region ChartInfo
def get_chart_infos(self):
stmt = select(ChartInfo)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_chart_infos_by_song_id(self, song_id: str):
stmt = select(ChartInfo).where(ChartInfo.song_id == song_id)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_chart_info(self, song_id: str, rating_class: int):
stmt = select(ChartInfo).where(
(ChartInfo.song_id == song_id) & (ChartInfo.rating_class == rating_class)
)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
# endregion
# region Chart
def get_charts_by_pack_id(self, pack_id: str):
stmt = select(Chart).where(Chart.set == pack_id)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_charts_by_song_id(self, song_id: str):
stmt = select(Chart).where(Chart.song_id == song_id)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_charts_by_constant(self, constant: int):
stmt = select(Chart).where(Chart.constant == constant)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_chart(self, song_id: str, rating_class: int):
stmt = select(Chart).where(
(Chart.song_id == song_id) & (Chart.rating_class == rating_class)
)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
# endregion
# region Score
def get_scores(self):
stmt = select(Score)
with self.sessionmaker() as session:
results = list(session.scalars(stmt))
return results
def get_score(self, score_id: int):
stmt = select(Score).where(Score.id == score_id)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
def get_score_best(self, song_id: str, rating_class: int):
stmt = select(ScoreBest).where(
(ScoreBest.song_id == song_id) & (ScoreBest.rating_class == rating_class)
)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
def insert_score(self, score: Score):
with self.sessionmaker() as session:
session.add(score)
session.commit()
def insert_scores(self, scores: Iterable[Score]):
with self.sessionmaker() as session:
session.add_all(scores)
session.commit()
def update_score(self, score: Score):
if score.id is None:
raise ValueError(
"Cannot determine which score to update, please specify `score.id`"
)
with self.sessionmaker() as session:
session.merge(score)
session.commit()
def delete_score(self, score: Score):
with self.sessionmaker() as session:
session.delete(score)
session.commit()
def recommend_charts(self, play_result: float, bounds: float = 0.1):
base_constant = math.ceil(play_result * 10)
results = []
results_id = []
with self.sessionmaker() as session:
for constant in range(base_constant - 20, base_constant + 1):
# from Pure Memory(EX+) to AA
score_modifier = (play_result * 10 - constant) / 10
if score_modifier >= 2.0:
min_score = 10000000
elif score_modifier >= 1.0:
min_score = 200000 * (score_modifier - 1) + 9800000
else:
min_score = 300000 * score_modifier + 9500000
min_score = int(min_score)
charts = self.get_charts_by_constant(constant)
for chart in charts:
score_best_stmt = select(ScoreBest).where(
(ScoreBest.song_id == chart.song_id)
& (ScoreBest.rating_class == chart.rating_class)
& (ScoreBest.score >= min_score)
& (play_result - bounds < ScoreBest.potential)
& (ScoreBest.potential < play_result + bounds)
)
if session.scalar(score_best_stmt):
chart_id = f"{chart.song_id},{chart.rating_class}"
if chart_id not in results_id:
results.append(chart)
results_id.append(chart_id)
return results
# endregion
def get_b30(self):
stmt = select(CalculatedPotential.b30).select_from(CalculatedPotential)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result
# region COUNT
def __count_table(self, base: Type[DeclarativeBase]):
stmt = select(func.count()).select_from(base)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result or 0
def __count_column(self, column: InstrumentedAttribute):
stmt = select(func.count(column))
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result or 0
def count_packs(self):
return self.__count_column(Pack.id)
def count_songs(self):
return self.__count_column(Song.id)
def count_difficulties(self):
return self.__count_table(Difficulty)
def count_chart_infos(self):
return self.__count_table(ChartInfo)
def count_complete_chart_infos(self):
stmt = (
select(func.count())
.select_from(ChartInfo)
.where((ChartInfo.constant != None) & (ChartInfo.notes != None))
)
with self.sessionmaker() as session:
result = session.scalar(stmt)
return result or 0
def count_charts(self):
return self.__count_table(Chart)
def count_scores(self):
return self.__count_column(Score.id)
def count_scores_calculated(self):
return self.__count_table(ScoreCalculated)
def count_scores_best(self):
return self.__count_table(ScoreBest)
# endregion

View File

@ -0,0 +1 @@
Generic single-database configuration.

View File

@ -0,0 +1,82 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from arcaea_offline.database.models._base import ModelBase
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = [ModelBase.metadata]
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import DateTime
from sqlalchemy.types import TypeDecorator
class ForceTimezoneDateTime(TypeDecorator):
"""
Store timezone aware timestamps as timezone naive UTC
https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: Optional[datetime], dialect):
if value is not None:
if not value.tzinfo or value.tzinfo.utcoffset(value) is None:
raise TypeError("datetime tzinfo is required")
value = value.astimezone(timezone.utc).replace(tzinfo=None)
return value
def process_result_value(self, value: Optional[datetime], dialect):
if value is not None:
value = value.replace(tzinfo=timezone.utc)
return value

View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,506 @@
"""v4 to v5
Revision ID: 0ca6733e40dc
Revises: a3f9d48b7de3
Create Date: 2025-05-31 11:38:25.575124
"""
from datetime import datetime, timezone
from typing import Any, Sequence, Union
from uuid import uuid4
import sqlalchemy as sa
from alembic import context, op
from arcaea_offline.database.migrations.legacies.v5 import ForceTimezoneDateTime
# revision identifiers, used by Alembic.
revision: str = "0ca6733e40dc"
down_revision: Union[str, None] = "a3f9d48b7de3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade(
*,
data_migration: bool = True,
data_migration_options: Any = None,
) -> None:
op.create_table(
"property",
sa.Column("key", sa.String(), nullable=False),
sa.Column("value", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("key", name=op.f("pk_property")),
)
op.create_table(
"pack",
sa.Column("id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("section", sa.String(), nullable=True),
sa.Column(
"is_world_extend", sa.Boolean(), server_default=sa.text("0"), nullable=False
),
sa.Column("plus_character", sa.Integer(), nullable=True),
sa.Column("append_parent_id", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["append_parent_id"],
["pack.id"],
name=op.f("fk_pack_append_parent_id_pack"),
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pack")),
)
with op.batch_alter_table("pack", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_pack_name"), ["name"], unique=False)
op.create_table(
"pack_localization",
sa.Column("id", sa.String(), nullable=False),
sa.Column("lang", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["id"],
["pack.id"],
name=op.f("fk_pack_localization_id_pack"),
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", "lang", name=op.f("pk_pack_localization")),
)
op.create_table(
"song",
sa.Column("pack_id", sa.String(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("idx", sa.Integer(), nullable=True),
sa.Column("title", sa.String(), nullable=True),
sa.Column("artist", sa.String(), nullable=True),
sa.Column(
"is_deleted", sa.Boolean(), server_default=sa.text("0"), nullable=False
),
sa.Column("added_at", ForceTimezoneDateTime(), nullable=False),
sa.Column("version", sa.String(), nullable=True),
sa.Column("bpm", sa.String(), nullable=True),
sa.Column("bpm_base", sa.Numeric(), nullable=True),
sa.Column(
"is_remote", sa.Boolean(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"is_unlockable_in_world",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"is_beyond_unlock_state_local",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column("purchase", sa.String(), nullable=True),
sa.Column("category", sa.String(), nullable=True),
sa.Column("side", sa.Integer(), nullable=True),
sa.Column("bg", sa.String(), nullable=True),
sa.Column("bg_inverse", sa.String(), nullable=True),
sa.Column("bg_day", sa.String(), nullable=True),
sa.Column("bg_night", sa.String(), nullable=True),
sa.Column("source", sa.String(), nullable=True),
sa.Column("source_copyright", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["pack_id"],
["pack.id"],
name=op.f("fk_song_pack_id_pack"),
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_song")),
)
with op.batch_alter_table("song", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_song_added_at"), ["added_at"], unique=False
)
batch_op.create_index(batch_op.f("ix_song_artist"), ["artist"], unique=False)
batch_op.create_index(batch_op.f("ix_song_title"), ["title"], unique=False)
op.create_table(
"difficulty",
sa.Column("song_id", sa.String(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column("rating", sa.Integer(), nullable=False),
sa.Column(
"is_rating_plus", sa.Boolean(), server_default=sa.text("0"), nullable=False
),
sa.Column("chart_designer", sa.String(), nullable=True),
sa.Column("jacket_designer", sa.String(), nullable=True),
sa.Column(
"has_overriding_audio",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"has_overriding_jacket",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column("jacket_night", sa.String(), nullable=True),
sa.Column("title", sa.String(), nullable=True),
sa.Column("artist", sa.String(), nullable=True),
sa.Column("bg", sa.String(), nullable=True),
sa.Column("bg_inverse", sa.String(), nullable=True),
sa.Column("bpm", sa.String(), nullable=True),
sa.Column("bpm_base", sa.Numeric(), nullable=True),
sa.Column("added_at", ForceTimezoneDateTime(), nullable=True),
sa.Column("version", sa.String(), nullable=True),
sa.Column(
"is_legacy11", sa.Boolean(), server_default=sa.text("0"), nullable=False
),
sa.ForeignKeyConstraint(
["song_id"],
["song.id"],
name=op.f("fk_difficulty_song_id_song"),
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("song_id", "rating_class", name=op.f("pk_difficulty")),
)
op.create_table(
"song_localization",
sa.Column("id", sa.String(), nullable=False),
sa.Column("lang", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=True),
sa.Column("source", sa.String(), nullable=True),
sa.Column(
"has_jacket", sa.Boolean(), server_default=sa.text("0"), nullable=False
),
sa.ForeignKeyConstraint(
["id"],
["song.id"],
name=op.f("fk_song_localization_id_song"),
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", "lang", name=op.f("pk_song_localization")),
)
op.create_table(
"chart_info",
sa.Column("song_id", sa.String(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column("constant", sa.Numeric(), nullable=False),
sa.Column("notes", sa.Integer(), nullable=False),
sa.Column(
"added_at",
ForceTimezoneDateTime(),
nullable=False,
),
sa.Column("version", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["song_id", "rating_class"],
["difficulty.song_id", "difficulty.rating_class"],
name=op.f("fk_chart_info_song_id_difficulty"),
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"song_id", "rating_class", "added_at", name=op.f("pk_chart_info")
),
)
op.create_table(
"difficulty_localization",
sa.Column("song_id", sa.String(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column("lang", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["song_id", "rating_class"],
["difficulty.song_id", "difficulty.rating_class"],
name=op.f("fk_difficulty_localization_song_id_difficulty"),
onupdate="CASCADE",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"song_id", "rating_class", "lang", name=op.f("pk_difficulty_localization")
),
)
op.drop_table("properties")
op.drop_table("packs")
op.drop_table("packs_localized")
op.drop_table("difficulties")
op.drop_table("songs")
op.drop_table("songs_localized")
op.drop_table("charts")
op.drop_table("charts_info")
op.drop_table("difficulties_localized")
op.rename_table("scores", "scores_old")
play_result_tbl = op.create_table(
"play_result",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", sa.Uuid(), nullable=False),
sa.Column("song_id", sa.String(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column("played_at", ForceTimezoneDateTime(), nullable=True),
sa.Column("score", sa.Integer(), nullable=False),
sa.Column("pure", sa.Integer(), nullable=True),
sa.Column("pure_early", sa.Integer(), nullable=True),
sa.Column("pure_late", sa.Integer(), nullable=True),
sa.Column("far", sa.Integer(), nullable=True),
sa.Column("far_early", sa.Integer(), nullable=True),
sa.Column("far_late", sa.Integer(), nullable=True),
sa.Column("lost", sa.Integer(), nullable=True),
sa.Column("max_recall", sa.Integer(), nullable=True),
sa.Column("clear_type", sa.Integer(), nullable=True),
sa.Column("modifier", sa.Integer(), nullable=True),
sa.Column("comment", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_play_result")),
sa.UniqueConstraint("uuid", name=op.f("uq_play_result_uuid")),
)
if data_migration:
conn = op.get_bind()
query = conn.execute(
sa.text(
"SELECT id, song_id, rating_class, score, pure, far, lost, "
" `date`, max_recall, modifier, clear_type, comment "
"FROM scores_old"
)
)
batch_size = 30
while True:
rows = query.fetchmany(batch_size)
if not rows:
break
rows_to_insert = []
for row in rows:
result = row._asdict()
date = result.pop("date")
result["uuid"] = uuid4()
result["played_at"] = (
datetime.fromtimestamp(date, tz=timezone.utc)
if date is not None
else None
)
rows_to_insert.append(result)
conn.execute(sa.insert(play_result_tbl), rows_to_insert)
op.drop_table("scores_old")
def downgrade() -> None:
raise NotImplementedError(
f"Downgrade not supported! ({context.get_context().get_current_revision()})"
)
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"difficulties_localized",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.INTEGER(), nullable=False),
sa.Column("title_ja", sa.TEXT(), nullable=True),
sa.Column("title_ko", sa.TEXT(), nullable=True),
sa.Column("title_zh_hans", sa.TEXT(), nullable=True),
sa.Column("title_zh_hant", sa.TEXT(), nullable=True),
sa.Column("artist_ja", sa.TEXT(), nullable=True),
sa.Column("artist_ko", sa.TEXT(), nullable=True),
sa.Column("artist_zh_hans", sa.TEXT(), nullable=True),
sa.Column("artist_zh_hant", sa.TEXT(), nullable=True),
sa.ForeignKeyConstraint(
["rating_class"],
["difficulties.rating_class"],
name=op.f("fk_difficulties_localized_rating_class_difficulties"),
),
sa.ForeignKeyConstraint(
["song_id"],
["difficulties.song_id"],
name=op.f("fk_difficulties_localized_song_id_difficulties"),
),
sa.PrimaryKeyConstraint(
"song_id", "rating_class", name=op.f("pk_difficulties_localized")
),
)
op.create_table(
"charts_info",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.INTEGER(), nullable=False),
sa.Column("constant", sa.INTEGER(), nullable=False),
sa.Column("notes", sa.INTEGER(), nullable=True),
sa.ForeignKeyConstraint(
["rating_class"],
["difficulties.rating_class"],
name=op.f("fk_charts_info_rating_class_difficulties"),
),
sa.ForeignKeyConstraint(
["song_id"],
["difficulties.song_id"],
name=op.f("fk_charts_info_song_id_difficulties"),
),
sa.PrimaryKeyConstraint("song_id", "rating_class", name=op.f("pk_charts_info")),
)
op.create_table(
"charts",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.INTEGER(), nullable=False),
sa.Column("name_en", sa.TEXT(), nullable=False),
sa.Column("name_jp", sa.TEXT(), nullable=True),
sa.Column("artist", sa.TEXT(), nullable=False),
sa.Column("bpm", sa.TEXT(), nullable=False),
sa.Column("bpm_base", sa.REAL(), nullable=False),
sa.Column("package_id", sa.TEXT(), nullable=False),
sa.Column("time", sa.INTEGER(), nullable=True),
sa.Column("side", sa.INTEGER(), nullable=False),
sa.Column("world_unlock", sa.BOOLEAN(), nullable=False),
sa.Column("remote_download", sa.BOOLEAN(), nullable=True),
sa.Column("bg", sa.TEXT(), nullable=False),
sa.Column("date", sa.INTEGER(), nullable=False),
sa.Column("version", sa.TEXT(), nullable=False),
sa.Column("difficulty", sa.INTEGER(), nullable=False),
sa.Column("rating", sa.INTEGER(), nullable=False),
sa.Column("note", sa.INTEGER(), nullable=False),
sa.Column("chart_designer", sa.TEXT(), nullable=True),
sa.Column("jacket_designer", sa.TEXT(), nullable=True),
sa.Column("jacket_override", sa.BOOLEAN(), nullable=False),
sa.Column("audio_override", sa.BOOLEAN(), nullable=False),
sa.PrimaryKeyConstraint("song_id", "rating_class"),
)
op.create_table(
"songs_localized",
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("title_ja", sa.TEXT(), nullable=True),
sa.Column("title_ko", sa.TEXT(), nullable=True),
sa.Column("title_zh_hans", sa.TEXT(), nullable=True),
sa.Column("title_zh_hant", sa.TEXT(), nullable=True),
sa.Column("search_title_ja", sa.TEXT(), nullable=True),
sa.Column("search_title_ko", sa.TEXT(), nullable=True),
sa.Column("search_title_zh_hans", sa.TEXT(), nullable=True),
sa.Column("search_title_zh_hant", sa.TEXT(), nullable=True),
sa.Column("search_artist_ja", sa.TEXT(), nullable=True),
sa.Column("search_artist_ko", sa.TEXT(), nullable=True),
sa.Column("search_artist_zh_hans", sa.TEXT(), nullable=True),
sa.Column("search_artist_zh_hant", sa.TEXT(), nullable=True),
sa.Column("source_ja", sa.TEXT(), nullable=True),
sa.Column("source_ko", sa.TEXT(), nullable=True),
sa.Column("source_zh_hans", sa.TEXT(), nullable=True),
sa.Column("source_zh_hant", sa.TEXT(), nullable=True),
sa.ForeignKeyConstraint(
["id"], ["songs.id"], name=op.f("fk_songs_localized_id_songs")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_songs_localized")),
)
op.create_table(
"packs",
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("name", sa.TEXT(), nullable=False),
sa.Column("description", sa.TEXT(), nullable=True),
sa.PrimaryKeyConstraint("id", name="fk_packs"),
)
op.create_table(
"properties",
sa.Column("key", sa.TEXT(), nullable=False),
sa.Column("value", sa.TEXT(), nullable=False),
sa.UniqueConstraint("key"),
)
op.create_table(
"songs",
sa.Column("idx", sa.INTEGER(), nullable=False),
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("title", sa.TEXT(), nullable=False),
sa.Column("artist", sa.TEXT(), nullable=False),
sa.Column("set", sa.TEXT(), nullable=False),
sa.Column("bpm", sa.TEXT(), nullable=True),
sa.Column("bpm_base", sa.FLOAT(), nullable=True),
sa.Column("audio_preview", sa.INTEGER(), nullable=True),
sa.Column("audio_preview_end", sa.INTEGER(), nullable=True),
sa.Column("side", sa.INTEGER(), nullable=True),
sa.Column("version", sa.TEXT(), nullable=True),
sa.Column("date", sa.INTEGER(), nullable=True),
sa.Column("bg", sa.TEXT(), nullable=True),
sa.Column("bg_inverse", sa.TEXT(), nullable=True),
sa.Column("bg_day", sa.TEXT(), nullable=True),
sa.Column("bg_night", sa.TEXT(), nullable=True),
sa.Column("source", sa.TEXT(), nullable=True),
sa.Column("source_copyright", sa.TEXT(), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("songs")),
)
op.create_table(
"difficulties",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.INTEGER(), nullable=False),
sa.Column("rating", sa.INTEGER(), nullable=False),
sa.Column("rating_plus", sa.BOOLEAN(), nullable=False),
sa.Column("chart_designer", sa.TEXT(), nullable=True),
sa.Column("jacket_desginer", sa.TEXT(), nullable=True),
sa.Column("audio_override", sa.BOOLEAN(), nullable=False),
sa.Column("jacket_override", sa.BOOLEAN(), nullable=False),
sa.Column("jacket_night", sa.TEXT(), nullable=True),
sa.Column("title", sa.TEXT(), nullable=True),
sa.Column("artist", sa.TEXT(), nullable=True),
sa.Column("bg", sa.TEXT(), nullable=True),
sa.Column("bg_inverse", sa.TEXT(), nullable=True),
sa.Column("bpm", sa.TEXT(), nullable=True),
sa.Column("bpm_base", sa.FLOAT(), nullable=True),
sa.Column("version", sa.TEXT(), nullable=True),
sa.Column("date", sa.INTEGER(), nullable=True),
sa.PrimaryKeyConstraint(
"song_id", "rating_class", name=op.f("pk_difficulties")
),
)
op.create_table(
"packs_localized",
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("name_ja", sa.TEXT(), nullable=True),
sa.Column("name_ko", sa.TEXT(), nullable=True),
sa.Column("name_zh_hans", sa.TEXT(), nullable=True),
sa.Column("name_zh_hant", sa.TEXT(), nullable=True),
sa.Column("description_ja", sa.TEXT(), nullable=True),
sa.Column("description_ko", sa.TEXT(), nullable=True),
sa.Column("description_zh_hans", sa.TEXT(), nullable=True),
sa.Column("description_zh_hant", sa.TEXT(), nullable=True),
sa.ForeignKeyConstraint(
["id"], ["packs.id"], name=op.f("fk_packs_localized_id_packs")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_packs_localized")),
)
op.create_table(
"scores",
sa.Column("id", sa.INTEGER(), nullable=False),
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.INTEGER(), nullable=False),
sa.Column("score", sa.INTEGER(), nullable=False),
sa.Column("pure", sa.INTEGER(), nullable=True),
sa.Column("far", sa.INTEGER(), nullable=True),
sa.Column("lost", sa.INTEGER(), nullable=True),
sa.Column("date", sa.INTEGER(), nullable=True),
sa.Column("max_recall", sa.INTEGER(), nullable=True),
sa.Column("modifier", sa.INTEGER(), nullable=True),
sa.Column("clear_type", sa.INTEGER(), nullable=True),
sa.Column("comment", sa.TEXT(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.drop_table("difficulty_localization")
op.drop_table("chart_info")
op.drop_table("song_localization")
op.drop_table("difficulty")
with op.batch_alter_table("song", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_song_title"))
batch_op.drop_index(batch_op.f("ix_song_artist"))
batch_op.drop_index(batch_op.f("ix_song_added_at"))
op.drop_table("song")
op.drop_table("pack_localization")
op.drop_table("property")
op.drop_table("play_result")
with op.batch_alter_table("pack", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_pack_name"))
op.drop_table("pack")
# ### end Alembic commands ###

View File

@ -0,0 +1,275 @@
"""v1 to v4
Revision ID: a3f9d48b7de3
Revises:
Create Date: 2024-11-24 00:03:07.697165
"""
from datetime import datetime, timezone
from typing import Mapping, Optional, Sequence, TypedDict, Union
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "a3f9d48b7de3"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
class V4DataMigrationOptions(TypedDict):
threshold_date: Optional[datetime]
def _data_migration_options(user_input: Optional[Mapping]):
options: V4DataMigrationOptions = {
"threshold_date": datetime(year=2017, month=1, day=23, tzinfo=timezone.utc),
}
if user_input is None:
return options
if not isinstance(user_input, dict):
raise TypeError("v4 migration: data migration options should be a dict object")
threshold_date = user_input.get("threshold_date")
if threshold_date is not None and not isinstance(threshold_date, datetime):
raise ValueError(
"v4 migration: threshold_date should be None or a datetime.datetime object"
)
options["threshold_date"] = threshold_date
return options
def upgrade(
*,
data_migration: bool = True,
data_migration_options: Optional[V4DataMigrationOptions] = None,
) -> None:
data_migration_options = _data_migration_options(data_migration_options)
threshold_date = data_migration_options["threshold_date"]
op.create_table(
"difficulties",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column("rating", sa.Integer(), nullable=False),
sa.Column("rating_plus", sa.Boolean(), nullable=False),
sa.Column("chart_designer", sa.TEXT(), nullable=True),
sa.Column("jacket_desginer", sa.TEXT(), nullable=True),
sa.Column("audio_override", sa.Boolean(), nullable=False),
sa.Column("jacket_override", sa.Boolean(), nullable=False),
sa.Column("jacket_night", sa.TEXT(), nullable=True),
sa.Column("title", sa.TEXT(), nullable=True),
sa.Column("artist", sa.TEXT(), nullable=True),
sa.Column("bg", sa.TEXT(), nullable=True),
sa.Column("bg_inverse", sa.TEXT(), nullable=True),
sa.Column("bpm", sa.TEXT(), nullable=True),
sa.Column("bpm_base", sa.Float(), nullable=True),
sa.Column("version", sa.TEXT(), nullable=True),
sa.Column("date", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("song_id", "rating_class", name="pk_difficulties"),
)
op.create_table(
"packs",
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("name", sa.TEXT(), nullable=False),
sa.Column("description", sa.TEXT(), nullable=True),
sa.PrimaryKeyConstraint("id", name="fk_packs"),
)
op.create_table(
"songs",
sa.Column("idx", sa.Integer(), nullable=False),
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("title", sa.TEXT(), nullable=False),
sa.Column("artist", sa.TEXT(), nullable=False),
sa.Column("set", sa.TEXT(), nullable=False),
sa.Column("bpm", sa.TEXT(), nullable=True),
sa.Column("bpm_base", sa.Float(), nullable=True),
sa.Column("audio_preview", sa.Integer(), nullable=True),
sa.Column("audio_preview_end", sa.Integer(), nullable=True),
sa.Column("side", sa.Integer(), nullable=True),
sa.Column("version", sa.TEXT(), nullable=True),
sa.Column("date", sa.Integer(), nullable=True),
sa.Column("bg", sa.TEXT(), nullable=True),
sa.Column("bg_inverse", sa.TEXT(), nullable=True),
sa.Column("bg_day", sa.TEXT(), nullable=True),
sa.Column("bg_night", sa.TEXT(), nullable=True),
sa.Column("source", sa.TEXT(), nullable=True),
sa.Column("source_copyright", sa.TEXT(), nullable=True),
sa.PrimaryKeyConstraint("id", name="songs"),
)
op.create_table(
"charts_info",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column(
"constant",
sa.Integer(),
nullable=False,
comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104.",
),
sa.Column("notes", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["rating_class"],
["difficulties.rating_class"],
name="fk_charts_info_rating_class_difficulties",
),
sa.ForeignKeyConstraint(
["song_id"],
["difficulties.song_id"],
name="fk_charts_info_song_id_difficulties",
),
sa.PrimaryKeyConstraint("song_id", "rating_class", name="pk_charts_info"),
)
op.create_table(
"difficulties_localized",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column("title_ja", sa.TEXT(), nullable=True),
sa.Column("title_ko", sa.TEXT(), nullable=True),
sa.Column("title_zh_hans", sa.TEXT(), nullable=True),
sa.Column("title_zh_hant", sa.TEXT(), nullable=True),
sa.Column("artist_ja", sa.TEXT(), nullable=True),
sa.Column("artist_ko", sa.TEXT(), nullable=True),
sa.Column("artist_zh_hans", sa.TEXT(), nullable=True),
sa.Column("artist_zh_hant", sa.TEXT(), nullable=True),
sa.ForeignKeyConstraint(
["rating_class"],
["difficulties.rating_class"],
name="fk_difficulties_localized_rating_class_difficulties",
),
sa.ForeignKeyConstraint(
["song_id"],
["difficulties.song_id"],
name="fk_difficulties_localized_song_id_difficulties",
),
sa.PrimaryKeyConstraint(
"song_id", "rating_class", name="pk_difficulties_localized"
),
)
op.create_table(
"packs_localized",
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("name_ja", sa.TEXT(), nullable=True),
sa.Column("name_ko", sa.TEXT(), nullable=True),
sa.Column("name_zh_hans", sa.TEXT(), nullable=True),
sa.Column("name_zh_hant", sa.TEXT(), nullable=True),
sa.Column("description_ja", sa.TEXT(), nullable=True),
sa.Column("description_ko", sa.TEXT(), nullable=True),
sa.Column("description_zh_hans", sa.TEXT(), nullable=True),
sa.Column("description_zh_hant", sa.TEXT(), nullable=True),
sa.ForeignKeyConstraint(
["id"],
["packs.id"],
name="fk_packs_localized_id_packs",
),
sa.PrimaryKeyConstraint("id", name="pk_packs_localized"),
)
op.create_table(
"songs_localized",
sa.Column("id", sa.TEXT(), nullable=False),
sa.Column("title_ja", sa.TEXT(), nullable=True),
sa.Column("title_ko", sa.TEXT(), nullable=True),
sa.Column("title_zh_hans", sa.TEXT(), nullable=True),
sa.Column("title_zh_hant", sa.TEXT(), nullable=True),
sa.Column("search_title_ja", sa.TEXT(), nullable=True, comment="JSON array"),
sa.Column("search_title_ko", sa.TEXT(), nullable=True, comment="JSON array"),
sa.Column(
"search_title_zh_hans", sa.TEXT(), nullable=True, comment="JSON array"
),
sa.Column(
"search_title_zh_hant", sa.TEXT(), nullable=True, comment="JSON array"
),
sa.Column("search_artist_ja", sa.TEXT(), nullable=True, comment="JSON array"),
sa.Column("search_artist_ko", sa.TEXT(), nullable=True, comment="JSON array"),
sa.Column(
"search_artist_zh_hans", sa.TEXT(), nullable=True, comment="JSON array"
),
sa.Column(
"search_artist_zh_hant", sa.TEXT(), nullable=True, comment="JSON array"
),
sa.Column("source_ja", sa.TEXT(), nullable=True),
sa.Column("source_ko", sa.TEXT(), nullable=True),
sa.Column("source_zh_hans", sa.TEXT(), nullable=True),
sa.Column("source_zh_hant", sa.TEXT(), nullable=True),
sa.ForeignKeyConstraint(
["id"],
["songs.id"],
name="fk_songs_localized_id_songs",
),
sa.PrimaryKeyConstraint("id", name="pk_songs_localized"),
)
op.drop_table("aliases")
op.drop_table("packages")
op.execute(sa.text("DROP VIEW IF EXISTS bests"))
op.execute(sa.text("DROP VIEW IF EXISTS calculated"))
op.execute(sa.text("DROP VIEW IF EXISTS calculated_potential"))
op.execute(sa.text("DROP VIEW IF EXISTS song_id_names"))
op.rename_table("scores", "scores_old")
scores_tbl = op.create_table(
"scores",
sa.Column("id", sa.Integer(), autoincrement=True, primary_key=True),
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("rating_class", sa.Integer(), nullable=False),
sa.Column("score", sa.Integer(), nullable=False),
sa.Column("pure", sa.Integer()),
sa.Column("far", sa.Integer()),
sa.Column("lost", sa.Integer()),
sa.Column("date", sa.Integer()),
sa.Column("max_recall", sa.Integer()),
sa.Column("modifier", sa.Integer(), comment="0: NORMAL, 1: EASY, 2: HARD"),
sa.Column(
"clear_type",
sa.Integer(),
comment="0: TRACK LOST, 1: NORMAL CLEAR, 2: FULL RECALL, "
"3: PURE MEMORY, 4: EASY CLEAR, 5: HARD CLEAR",
),
sa.Column("comment", sa.TEXT()),
)
if data_migration:
conn = op.get_bind()
query = conn.execute(
sa.text(
"SELECT id, song_id, rating_class, score, time, pure, far, lost, max_recall, clear_type "
"FROM scores_old"
)
)
batch_size = 30
while True:
rows = query.fetchmany(batch_size)
if not rows:
break
rows_to_insert = []
for row in rows:
result = row._asdict()
result["date"] = datetime.fromtimestamp(
result.pop("time"), tz=timezone.utc
)
if threshold_date is not None and result["date"] <= threshold_date:
result["date"] = None
result["date"] = (
int(result["date"].timestamp())
if result["date"] is not None
else None
)
rows_to_insert.append(result)
conn.execute(sa.insert(scores_tbl), rows_to_insert)
op.drop_table("scores_old")
def downgrade() -> None:
raise NotImplementedError(
f"Downgrade not supported! ({context.get_context().get_current_revision()})"
)

View File

@ -0,0 +1,32 @@
from ._base import ModelBase, ModelViewBase
from .chart_info import ChartInfo
from .config import Property
from .difficulty import Difficulty, DifficultyLocalization
from .pack import Pack, PackLocalization
from .song import Song, SongLocalization
from .chart import Chart # isort: skip
from .play_result import (
CalculatedPotential,
PlayResult,
PlayResultBest,
PlayResultCalculated,
) # isort: skip
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalization",
"ModelBase",
"ModelViewBase",
"Pack",
"PackLocalization",
"PlayResult",
"PlayResultBest",
"PlayResultCalculated",
"Property",
"Song",
"SongLocalization",
]

View File

@ -1,31 +1,30 @@
from datetime import datetime
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.exc import DetachedInstanceError
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from .._custom_types import DbIntEnum, TZDateTime
from ._types import ForceTimezoneDateTime
TYPE_ANNOTATION_MAP = {
datetime: TZDateTime,
ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass),
ArcaeaSongSide: DbIntEnum(ArcaeaSongSide),
ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType),
ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier),
datetime: ForceTimezoneDateTime,
}
class ModelsV5Base(DeclarativeBase):
class ModelBase(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP
metadata = MetaData(
naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_`%(constraint_name)s`",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
)
class ModelsV5ViewBase(DeclarativeBase):
class ModelViewBase(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP
@ -34,7 +33,7 @@ class ReprHelper:
def _repr(self, **kwargs) -> str:
"""
Helper for __repr__
SQLAlchemy model __repr__ helper
https://stackoverflow.com/a/55749579/16484891

View File

@ -1,46 +0,0 @@
from datetime import datetime, timezone
from enum import IntEnum
from typing import Optional, Type
from sqlalchemy import DateTime, Integer
from sqlalchemy.types import TypeDecorator
class DbIntEnum(TypeDecorator):
"""sqlalchemy `TypeDecorator` for `IntEnum`s"""
impl = Integer
cache_ok = True
def __init__(self, enum_class: Type[IntEnum]):
super().__init__()
self.enum_class = enum_class
def process_bind_param(self, value: Optional[IntEnum], dialect) -> Optional[int]:
return None if value is None else value.value
def process_result_value(self, value: Optional[int], dialect) -> Optional[IntEnum]:
return None if value is None else self.enum_class(value)
class TZDateTime(TypeDecorator):
"""
Store Timezone Aware Timestamps as Timezone Naive UTC
https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: Optional[datetime], dialect):
if value is not None:
if not value.tzinfo or value.tzinfo.utcoffset(value) is None:
raise TypeError("tzinfo is required")
value = value.astimezone(timezone.utc).replace(tzinfo=None)
return value
def process_result_value(self, value: Optional[datetime], dialect):
if value is not None:
value = value.replace(tzinfo=timezone.utc)
return value

View File

@ -0,0 +1,28 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import DateTime
from sqlalchemy.types import TypeDecorator
class ForceTimezoneDateTime(TypeDecorator):
"""
Store timezone aware timestamps as timezone naive UTC
https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: Optional[datetime], dialect):
if value is not None:
if not value.tzinfo or value.tzinfo.utcoffset(value) is None:
raise TypeError("datetime tzinfo is required")
value = value.astimezone(timezone.utc).replace(tzinfo=None)
return value
def process_result_value(self, value: Optional[datetime], dialect):
if value is not None:
value = value.replace(tzinfo=timezone.utc)
return value

View File

@ -0,0 +1,85 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.orm import Mapped
from sqlalchemy_utils import create_view
from ._base import ModelBase, ModelViewBase, ReprHelper
from .chart_info import ChartInfo
from .difficulty import Difficulty
from .song import Song
class Chart(ModelBase, ReprHelper):
__tablename__ = "charts"
song_idx: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[int]
rating: Mapped[int]
is_rating_plus: Mapped[bool]
title: Mapped[str]
artist: Mapped[str]
pack_id: Mapped[str]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[int]]
version: Mapped[Optional[str]]
added_at: Mapped[Optional[datetime]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
has_overriding_audio: Mapped[bool]
has_overriding_jacket: Mapped[bool]
jacket_night: Mapped[Optional[str]]
constant: Mapped[int]
notes: Mapped[Optional[int]]
__table__ = create_view(
name=__tablename__,
selectable=select(
Song.idx.label("song_idx"),
Difficulty.song_id,
Difficulty.rating_class,
Difficulty.rating,
Difficulty.is_rating_plus,
func.coalesce(Difficulty.title, Song.title).label("title"),
func.coalesce(Difficulty.artist, Song.artist).label("artist"),
Song.pack_id,
func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"),
func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"),
Song.side,
func.coalesce(Difficulty.version, Song.version).label("version"),
func.coalesce(Difficulty.added_at, Song.added_at).label("added_at"),
func.coalesce(Difficulty.bg, Song.bg).label("bg"),
func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"),
Song.bg_day,
Song.bg_night,
Song.source,
Song.source_copyright,
Difficulty.chart_designer,
Difficulty.jacket_designer,
Difficulty.has_overriding_audio,
Difficulty.has_overriding_jacket,
Difficulty.jacket_night,
ChartInfo.constant,
ChartInfo.notes,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(Song, Difficulty.song_id == Song.id),
metadata=ModelViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -0,0 +1,33 @@
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKeyConstraint, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ._base import ModelBase, ReprHelper
from ._types import ForceTimezoneDateTime
if TYPE_CHECKING:
from .difficulty import Difficulty
class ChartInfo(ModelBase, ReprHelper):
__tablename__ = "chart_info"
__table_args__ = (
ForeignKeyConstraint(
["song_id", "rating_class"],
["difficulty.song_id", "difficulty.rating_class"],
onupdate="CASCADE",
ondelete="CASCADE",
),
)
difficulty: Mapped["Difficulty"] = relationship(back_populates="chart_info_list")
song_id: Mapped[str] = mapped_column(String, primary_key=True)
rating_class: Mapped[int] = mapped_column(Integer, primary_key=True)
constant: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
notes: Mapped[int] = mapped_column(Integer)
added_at: Mapped[datetime] = mapped_column(ForceTimezoneDateTime, primary_key=True)
version: Mapped[Optional[str]] = mapped_column(String)

View File

@ -1,12 +1,12 @@
from sqlalchemy.orm import Mapped, mapped_column
from .base import ModelsV5Base, ReprHelper
from ._base import ModelBase, ReprHelper
__all__ = ["Property"]
class Property(ModelsV5Base, ReprHelper):
__tablename__ = "properties"
class Property(ModelBase, ReprHelper):
__tablename__ = "property"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[str] = mapped_column()

View File

@ -0,0 +1,93 @@
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
Boolean,
ForeignKey,
ForeignKeyConstraint,
Integer,
Numeric,
String,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ._base import ModelBase, ReprHelper
from ._types import ForceTimezoneDateTime
if TYPE_CHECKING:
from .chart_info import ChartInfo
from .song import Song
class Difficulty(ModelBase, ReprHelper):
__tablename__ = "difficulty"
song_id: Mapped[str] = mapped_column(
ForeignKey("song.id", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True,
)
song: Mapped["Song"] = relationship(back_populates="difficulties")
localization_entries: Mapped[list["DifficultyLocalization"]] = relationship(
back_populates="difficulty",
cascade="all, delete",
passive_deletes=True,
)
chart_info_list: Mapped[list["ChartInfo"]] = relationship(
back_populates="difficulty",
cascade="all, delete",
passive_deletes=True,
)
rating_class: Mapped[int] = mapped_column(Integer, primary_key=True)
rating: Mapped[int] = mapped_column(Integer, nullable=False)
is_rating_plus: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
chart_designer: Mapped[Optional[str]] = mapped_column(String)
jacket_designer: Mapped[Optional[str]] = mapped_column(String)
has_overriding_audio: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
has_overriding_jacket: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
jacket_night: Mapped[Optional[str]] = mapped_column(String)
title: Mapped[Optional[str]] = mapped_column(String)
artist: Mapped[Optional[str]] = mapped_column(String)
bg: Mapped[Optional[str]] = mapped_column(String)
bg_inverse: Mapped[Optional[str]] = mapped_column(String)
bpm: Mapped[Optional[str]] = mapped_column(String)
bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True))
added_at: Mapped[Optional[datetime]] = mapped_column(ForceTimezoneDateTime)
version: Mapped[Optional[str]] = mapped_column(String)
is_legacy11: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
class DifficultyLocalization(ModelBase, ReprHelper):
__tablename__ = "difficulty_localization"
__table_args__ = (
ForeignKeyConstraint(
["song_id", "rating_class"],
["difficulty.song_id", "difficulty.rating_class"],
onupdate="CASCADE",
ondelete="CASCADE",
),
)
difficulty: Mapped["Difficulty"] = relationship(
back_populates="localization_entries"
)
song_id: Mapped[str] = mapped_column(String, primary_key=True)
rating_class: Mapped[int] = mapped_column(Integer, primary_key=True)
lang: Mapped[str] = mapped_column(String, primary_key=True)
title: Mapped[Optional[str]] = mapped_column(String)
artist: Mapped[Optional[str]] = mapped_column(String)

View File

@ -0,0 +1,61 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ._base import ModelBase, ReprHelper
if TYPE_CHECKING:
from .song import Song
class Pack(ModelBase, ReprHelper):
__tablename__ = "pack"
songs: Mapped[list["Song"]] = relationship(
back_populates="pack",
cascade="all, delete",
passive_deletes=True,
)
localized_entries: Mapped[list["PackLocalization"]] = relationship(
back_populates="pack",
cascade="all, delete",
passive_deletes=True,
)
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[Optional[str]] = mapped_column(String, index=True)
description: Mapped[Optional[str]] = mapped_column(Text)
section: Mapped[Optional[str]] = mapped_column(String)
is_world_extend: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
plus_character: Mapped[Optional[int]] = mapped_column(Integer)
append_parent_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("pack.id", onupdate="CASCADE", ondelete="CASCADE")
)
parent: Mapped["Pack"] = relationship(
"Pack",
back_populates="appendages",
cascade="all, delete",
passive_deletes=True,
remote_side=[id],
)
appendages: Mapped[list["Pack"]] = relationship("Pack", back_populates="parent")
class PackLocalization(ModelBase, ReprHelper):
__tablename__ = "pack_localization"
pack: Mapped["Pack"] = relationship(back_populates="localized_entries")
id: Mapped[str] = mapped_column(
ForeignKey("pack.id", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True,
)
lang: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[Optional[str]] = mapped_column(String)
description: Mapped[Optional[str]] = mapped_column(Text)

View File

@ -1,18 +1,14 @@
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import ForeignKey, and_, case, func, inspect, select, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Integer, String, Text, Uuid, case, func, inspect, select, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy_utils import create_view
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from .arcaea import ChartInfo, Difficulty
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
from ._base import ModelBase, ModelViewBase, ReprHelper
from .chart_info import ChartInfo
from .difficulty import Difficulty
__all__ = [
"CalculatedPotential",
@ -22,59 +18,53 @@ __all__ = [
]
class PlayResult(ModelsV5Base, ReprHelper):
__tablename__ = "play_results"
class PlayResult(ModelBase, ReprHelper):
__tablename__ = "play_result"
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
index=True,
uuid: Mapped[UUID] = mapped_column(
Uuid, nullable=False, unique=True, default=lambda: uuid4()
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
index=True,
song_id: Mapped[str] = mapped_column(String)
rating_class: Mapped[int] = mapped_column(Integer)
played_at: Mapped[Optional[datetime]] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
score: Mapped[int]
pure: Mapped[Optional[int]]
pure_early: Mapped[Optional[int]]
pure_late: Mapped[Optional[int]]
far: Mapped[Optional[int]]
far_early: Mapped[Optional[int]]
far_late: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
comment: Mapped[Optional[str]]
difficulty: Mapped[Difficulty] = relationship(
primaryjoin=and_(
song_id == Difficulty.song_id,
rating_class == Difficulty.rating_class,
),
viewonly=True,
)
clear_type: Mapped[Optional[int]]
modifier: Mapped[Optional[int]]
comment: Mapped[Optional[str]] = mapped_column(Text)
# How to create an SQL View with SQLAlchemy?
# https://stackoverflow.com/a/53253105/16484891
# CC BY-SA 4.0
class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
class PlayResultCalculated(ModelViewBase, ReprHelper):
__tablename__ = "play_results_calculated"
id: Mapped[int]
uuid: Mapped[UUID]
song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass]
rating_class: Mapped[int]
score: Mapped[int]
pure: Mapped[Optional[int]]
pure_early: Mapped[Optional[int]]
pure_late: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
far_early: Mapped[Optional[int]]
far_late: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]]
played_at: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
modifier: Mapped[Optional[int]]
clear_type: Mapped[Optional[int]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
@ -106,7 +96,7 @@ class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
).label("shiny_pure"),
PlayResult.far,
PlayResult.lost,
PlayResult.date,
PlayResult.played_at,
PlayResult.max_recall,
PlayResult.modifier,
PlayResult.clear_type,
@ -137,26 +127,31 @@ class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
(Difficulty.song_id == PlayResult.song_id)
& (Difficulty.rating_class == PlayResult.rating_class),
),
metadata=ModelsV5ViewBase.metadata,
metadata=ModelViewBase.metadata,
cascade_on_drop=False,
)
class PlayResultBest(ModelsV5ViewBase, ReprHelper):
class PlayResultBest(ModelViewBase, ReprHelper):
__tablename__ = "play_results_best"
id: Mapped[int]
uuid: Mapped[UUID]
song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass]
rating_class: Mapped[int]
score: Mapped[int]
pure: Mapped[Optional[int]]
pure_early: Mapped[Optional[int]]
pure_late: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
far_early: Mapped[Optional[int]]
far_late: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]]
played_at: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
modifier: Mapped[Optional[int]]
clear_type: Mapped[Optional[int]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
@ -173,12 +168,12 @@ class PlayResultBest(ModelsV5ViewBase, ReprHelper):
.select_from(PlayResultCalculated)
.group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class)
.order_by(PlayResultCalculated.potential.desc()),
metadata=ModelsV5ViewBase.metadata,
metadata=ModelViewBase.metadata,
cascade_on_drop=False,
)
class CalculatedPotential(ModelsV5ViewBase, ReprHelper):
class CalculatedPotential(ModelViewBase, ReprHelper):
__tablename__ = "calculated_potential"
b30: Mapped[float]
@ -192,6 +187,6 @@ class CalculatedPotential(ModelsV5ViewBase, ReprHelper):
__table__ = create_view(
name=__tablename__,
selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")),
metadata=ModelsV5ViewBase.metadata,
metadata=ModelViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -0,0 +1,85 @@
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ._base import ModelBase, ReprHelper
from ._types import ForceTimezoneDateTime
if TYPE_CHECKING:
from .difficulty import Difficulty
from .pack import Pack
class Song(ModelBase, ReprHelper):
__tablename__ = "song"
pack_id: Mapped[str] = mapped_column(
ForeignKey("pack.id", onupdate="CASCADE", ondelete="CASCADE")
)
pack: Mapped["Pack"] = relationship(back_populates="songs")
difficulties: Mapped[list["Difficulty"]] = relationship(
back_populates="song",
cascade="all, delete",
passive_deletes=True,
)
localized_entries: Mapped[list["SongLocalization"]] = relationship(
back_populates="song",
cascade="all, delete",
passive_deletes=True,
)
id: Mapped[str] = mapped_column(String, primary_key=True)
idx: Mapped[Optional[int]] = mapped_column(Integer)
title: Mapped[Optional[str]] = mapped_column(String, index=True)
artist: Mapped[Optional[str]] = mapped_column(String, index=True)
is_deleted: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
added_at: Mapped[datetime] = mapped_column(
ForceTimezoneDateTime, nullable=False, index=True
)
version: Mapped[Optional[str]] = mapped_column(String)
bpm: Mapped[Optional[str]] = mapped_column(String)
bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True))
is_remote: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
is_unlockable_in_world: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
is_beyond_unlock_state_local: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)
purchase: Mapped[Optional[str]] = mapped_column(String)
category: Mapped[Optional[str]] = mapped_column(String)
side: Mapped[Optional[int]] = mapped_column(Integer)
bg: Mapped[Optional[str]] = mapped_column(String)
bg_inverse: Mapped[Optional[str]] = mapped_column(String)
bg_day: Mapped[Optional[str]] = mapped_column(String)
bg_night: Mapped[Optional[str]] = mapped_column(String)
source: Mapped[Optional[str]] = mapped_column(String)
source_copyright: Mapped[Optional[str]] = mapped_column(String)
class SongLocalization(ModelBase, ReprHelper):
__tablename__ = "song_localization"
song: Mapped["Song"] = relationship(back_populates="localized_entries")
id: Mapped[str] = mapped_column(
ForeignKey("song.id", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True,
)
lang: Mapped[str] = mapped_column(String, primary_key=True)
title: Mapped[Optional[str]] = mapped_column(String)
source: Mapped[Optional[str]] = mapped_column(String)
has_jacket: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0")
)

View File

@ -1,42 +0,0 @@
from .config import ConfigBase, Property
from .scores import (
CalculatedPotential,
Score,
ScoreBest,
ScoreCalculated,
ScoresBase,
ScoresViewBase,
)
from .songs import (
Chart,
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongsBase,
SongsViewBase,
)
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"ConfigBase",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Property",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
"Song",
"SongLocalized",
"SongsBase",
"SongsViewBase",
]

View File

@ -1,36 +0,0 @@
# pylint: disable=too-few-public-methods
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.exc import DetachedInstanceError
class ReprHelper:
# pylint: disable=no-member
def _repr(self, **kwargs) -> str:
"""
Helper for __repr__
https://stackoverflow.com/a/55749579/16484891
CC BY-SA 4.0
"""
field_strings = []
at_least_one_attached_attribute = False
for key, field in kwargs.items():
try:
field_strings.append(f"{key}={field!r}")
except DetachedInstanceError:
field_strings.append(f"{key}=DetachedInstanceError")
else:
at_least_one_attached_attribute = True
if at_least_one_attached_attribute:
return f"<{self.__class__.__name__}({','.join(field_strings)})>"
return f"<{self.__class__.__name__} {id(self)}>"
def __repr__(self):
if isinstance(self, DeclarativeBase):
return self._repr(
**{c.key: getattr(self, c.key) for c in self.__table__.columns}
)
return super().__repr__()

View File

@ -1,22 +0,0 @@
# pylint: disable=too-few-public-methods
from sqlalchemy import TEXT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from .common import ReprHelper
__all__ = [
"ConfigBase",
"Property",
]
class ConfigBase(DeclarativeBase, ReprHelper):
pass
class Property(ConfigBase):
__tablename__ = "properties"
key: Mapped[str] = mapped_column(TEXT(), primary_key=True)
value: Mapped[str] = mapped_column(TEXT())

View File

@ -1,188 +0,0 @@
# pylint: disable=too-few-public-methods, duplicate-code
from typing import Optional
from sqlalchemy import TEXT, case, func, inspect, select, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy_utils import create_view
from .common import ReprHelper
from .songs import ChartInfo, Difficulty
__all__ = [
"CalculatedPotential",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
]
class ScoresBase(DeclarativeBase, ReprHelper):
pass
class Score(ScoresBase):
__tablename__ = "scores"
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)
song_id: Mapped[str] = mapped_column(TEXT())
rating_class: Mapped[int]
score: Mapped[int]
pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[int]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[int]] = mapped_column(
comment="0: NORMAL, 1: EASY, 2: HARD"
)
clear_type: Mapped[Optional[int]] = mapped_column(
comment="0: TRACK LOST, 1: NORMAL CLEAR, 2: FULL RECALL, "
"3: PURE MEMORY, 4: EASY CLEAR, 5: HARD CLEAR"
)
comment: Mapped[Optional[str]] = mapped_column(TEXT())
# How to create an SQL View with SQLAlchemy?
# https://stackoverflow.com/a/53253105/16484891
# CC BY-SA 4.0
class ScoresViewBase(DeclarativeBase, ReprHelper):
pass
class ScoreCalculated(ScoresViewBase):
__tablename__ = "scores_calculated"
id: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[int]
score: Mapped[int]
pure: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[int]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[int]]
clear_type: Mapped[Optional[int]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
__table__ = create_view(
name=__tablename__,
selectable=select(
Score.id,
Difficulty.song_id,
Difficulty.rating_class,
Score.score,
Score.pure,
(
case(
(
(
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)
+ (Score.far * 0.5 * 10000000.0 / ChartInfo.notes)
),
),
else_=text("NULL"),
)
).label("shiny_pure"),
Score.far,
Score.lost,
Score.date,
Score.max_recall,
Score.modifier,
Score.clear_type,
case(
(Score.score >= 10000000, ChartInfo.constant / 10.0 + 2),
(
Score.score >= 9800000,
ChartInfo.constant / 10.0 + 1 + (Score.score - 9800000) / 200000.0,
),
else_=func.max(
(ChartInfo.constant / 10.0) + (Score.score - 9500000) / 300000.0,
0,
),
).label("potential"),
Score.comment,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(
Score,
(Difficulty.song_id == Score.song_id)
& (Difficulty.rating_class == Score.rating_class),
),
metadata=ScoresViewBase.metadata,
cascade_on_drop=False,
)
class ScoreBest(ScoresViewBase):
__tablename__ = "scores_best"
id: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[int]
score: Mapped[int]
pure: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[int]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[int]]
clear_type: Mapped[Optional[int]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
__table__ = create_view(
name=__tablename__,
selectable=select(
*[
col
for col in inspect(ScoreCalculated).columns
if col.name != "potential"
],
func.max(ScoreCalculated.potential).label("potential"),
)
.select_from(ScoreCalculated)
.group_by(ScoreCalculated.song_id, ScoreCalculated.rating_class)
.order_by(ScoreCalculated.potential.desc()),
metadata=ScoresViewBase.metadata,
cascade_on_drop=False,
)
class CalculatedPotential(ScoresViewBase):
__tablename__ = "calculated_potential"
b30: Mapped[float]
_select_bests_subquery = (
select(ScoreBest.potential.label("b30_sum"))
.order_by(ScoreBest.potential.desc())
.limit(30)
.subquery()
)
__table__ = create_view(
name=__tablename__,
selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")),
metadata=ScoresViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -1,241 +0,0 @@
# pylint: disable=too-few-public-methods, duplicate-code
from typing import Optional
from sqlalchemy import TEXT, ForeignKey, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy_utils import create_view
from .common import ReprHelper
__all__ = [
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Song",
"SongLocalized",
"SongsBase",
"SongsViewBase",
]
class SongsBase(DeclarativeBase, ReprHelper):
pass
class Pack(SongsBase):
__tablename__ = "packs"
id: Mapped[str] = mapped_column(TEXT(), primary_key=True)
name: Mapped[str] = mapped_column(TEXT())
description: Mapped[Optional[str]] = mapped_column(TEXT())
class PackLocalized(SongsBase):
__tablename__ = "packs_localized"
id: Mapped[str] = mapped_column(ForeignKey("packs.id"), primary_key=True)
name_ja: Mapped[Optional[str]] = mapped_column(TEXT())
name_ko: Mapped[Optional[str]] = mapped_column(TEXT())
name_zh_hans: Mapped[Optional[str]] = mapped_column(TEXT())
name_zh_hant: Mapped[Optional[str]] = mapped_column(TEXT())
description_ja: Mapped[Optional[str]] = mapped_column(TEXT())
description_ko: Mapped[Optional[str]] = mapped_column(TEXT())
description_zh_hans: Mapped[Optional[str]] = mapped_column(TEXT())
description_zh_hant: Mapped[Optional[str]] = mapped_column(TEXT())
class Song(SongsBase):
__tablename__ = "songs"
idx: Mapped[int]
id: Mapped[str] = mapped_column(TEXT(), primary_key=True)
title: Mapped[str] = mapped_column(TEXT())
artist: Mapped[str] = mapped_column(TEXT())
set: Mapped[str] = mapped_column(TEXT())
bpm: Mapped[Optional[str]] = mapped_column(TEXT())
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[int]]
version: Mapped[Optional[str]] = mapped_column(TEXT())
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]] = mapped_column(TEXT())
bg_inverse: Mapped[Optional[str]] = mapped_column(TEXT())
bg_day: Mapped[Optional[str]] = mapped_column(TEXT())
bg_night: Mapped[Optional[str]] = mapped_column(TEXT())
source: Mapped[Optional[str]] = mapped_column(TEXT())
source_copyright: Mapped[Optional[str]] = mapped_column(TEXT())
class SongLocalized(SongsBase):
__tablename__ = "songs_localized"
id: Mapped[str] = mapped_column(ForeignKey("songs.id"), primary_key=True)
title_ja: Mapped[Optional[str]] = mapped_column(TEXT())
title_ko: Mapped[Optional[str]] = mapped_column(TEXT())
title_zh_hans: Mapped[Optional[str]] = mapped_column(TEXT())
title_zh_hant: Mapped[Optional[str]] = mapped_column(TEXT())
search_title_ja: Mapped[Optional[str]] = mapped_column(TEXT(), comment="JSON array")
search_title_ko: Mapped[Optional[str]] = mapped_column(TEXT(), comment="JSON array")
search_title_zh_hans: Mapped[Optional[str]] = mapped_column(
TEXT(), comment="JSON array"
)
search_title_zh_hant: Mapped[Optional[str]] = mapped_column(
TEXT(), comment="JSON array"
)
search_artist_ja: Mapped[Optional[str]] = mapped_column(
TEXT(), comment="JSON array"
)
search_artist_ko: Mapped[Optional[str]] = mapped_column(
TEXT(), comment="JSON array"
)
search_artist_zh_hans: Mapped[Optional[str]] = mapped_column(
TEXT(), comment="JSON array"
)
search_artist_zh_hant: Mapped[Optional[str]] = mapped_column(
TEXT(), comment="JSON array"
)
source_ja: Mapped[Optional[str]] = mapped_column(TEXT())
source_ko: Mapped[Optional[str]] = mapped_column(TEXT())
source_zh_hans: Mapped[Optional[str]] = mapped_column(TEXT())
source_zh_hant: Mapped[Optional[str]] = mapped_column(TEXT())
class Difficulty(SongsBase):
__tablename__ = "difficulties"
song_id: Mapped[str] = mapped_column(TEXT(), primary_key=True)
rating_class: Mapped[int] = mapped_column(primary_key=True)
rating: Mapped[int]
rating_plus: Mapped[bool]
chart_designer: Mapped[Optional[str]] = mapped_column(TEXT())
jacket_desginer: Mapped[Optional[str]] = mapped_column(TEXT())
audio_override: Mapped[bool]
jacket_override: Mapped[bool]
jacket_night: Mapped[Optional[str]] = mapped_column(TEXT())
title: Mapped[Optional[str]] = mapped_column(TEXT())
artist: Mapped[Optional[str]] = mapped_column(TEXT())
bg: Mapped[Optional[str]] = mapped_column(TEXT())
bg_inverse: Mapped[Optional[str]] = mapped_column(TEXT())
bpm: Mapped[Optional[str]] = mapped_column(TEXT())
bpm_base: Mapped[Optional[float]]
version: Mapped[Optional[str]] = mapped_column(TEXT())
date: Mapped[Optional[int]]
class DifficultyLocalized(SongsBase):
__tablename__ = "difficulties_localized"
song_id: Mapped[str] = mapped_column(
ForeignKey("difficulties.song_id"), primary_key=True
)
rating_class: Mapped[str] = mapped_column(
ForeignKey("difficulties.rating_class"), primary_key=True
)
title_ja: Mapped[Optional[str]] = mapped_column(TEXT())
title_ko: Mapped[Optional[str]] = mapped_column(TEXT())
title_zh_hans: Mapped[Optional[str]] = mapped_column(TEXT())
title_zh_hant: Mapped[Optional[str]] = mapped_column(TEXT())
artist_ja: Mapped[Optional[str]] = mapped_column(TEXT())
artist_ko: Mapped[Optional[str]] = mapped_column(TEXT())
artist_zh_hans: Mapped[Optional[str]] = mapped_column(TEXT())
artist_zh_hant: Mapped[Optional[str]] = mapped_column(TEXT())
class ChartInfo(SongsBase):
__tablename__ = "charts_info"
song_id: Mapped[str] = mapped_column(
ForeignKey("difficulties.song_id"), primary_key=True
)
rating_class: Mapped[str] = mapped_column(
ForeignKey("difficulties.rating_class"), primary_key=True
)
constant: Mapped[int] = mapped_column(
comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104."
)
notes: Mapped[Optional[int]]
class SongsViewBase(DeclarativeBase, ReprHelper):
pass
class Chart(SongsViewBase):
__tablename__ = "charts"
song_idx: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[int]
rating: Mapped[int]
rating_plus: Mapped[bool]
title: Mapped[str]
artist: Mapped[str]
set: Mapped[str]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[int]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
audio_override: Mapped[bool]
jacket_override: Mapped[bool]
jacket_night: Mapped[Optional[str]]
constant: Mapped[int]
notes: Mapped[Optional[int]]
__table__ = create_view(
name=__tablename__,
selectable=select(
Song.idx.label("song_idx"),
Difficulty.song_id,
Difficulty.rating_class,
Difficulty.rating,
Difficulty.rating_plus,
func.coalesce(Difficulty.title, Song.title).label("title"),
func.coalesce(Difficulty.artist, Song.artist).label("artist"),
Song.set,
func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"),
func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"),
Song.audio_preview,
Song.audio_preview_end,
Song.side,
func.coalesce(Difficulty.version, Song.version).label("version"),
func.coalesce(Difficulty.date, Song.date).label("date"),
func.coalesce(Difficulty.bg, Song.bg).label("bg"),
func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"),
Song.bg_day,
Song.bg_night,
Song.source,
Song.source_copyright,
Difficulty.chart_designer,
Difficulty.jacket_desginer,
Difficulty.audio_override,
Difficulty.jacket_override,
Difficulty.jacket_night,
ChartInfo.constant,
ChartInfo.notes,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(Song, Difficulty.song_id == Song.id),
metadata=SongsViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -1,38 +0,0 @@
from .arcaea import (
Chart,
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongSearchWord,
)
from .base import ModelsV5Base, ModelsV5ViewBase
from .config import Property
from .play_results import (
CalculatedPotential,
PlayResult,
PlayResultBest,
PlayResultCalculated,
)
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"ModelsV5Base",
"ModelsV5ViewBase",
"Pack",
"PackLocalized",
"PlayResult",
"PlayResultBest",
"PlayResultCalculated",
"Property",
"Song",
"SongLocalized",
"SongSearchWord",
]

View File

@ -1,284 +0,0 @@
from typing import List, Optional
from sqlalchemy import Enum, ForeignKey, and_, func, select
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy_utils import create_view
from arcaea_offline.constants.enums.arcaea import (
ArcaeaLanguage,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
__all__ = [
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Song",
"SongLocalized",
]
_ArcaeaLanguageEnumType = Enum(
ArcaeaLanguage, native_enum=False, values_callable=lambda e: [x.value for x in e]
)
class Pack(ModelsV5Base, ReprHelper):
__tablename__ = "packs"
id: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str]
description: Mapped[Optional[str]]
songs: Mapped[List["Song"]] = relationship(back_populates="pack", viewonly=True)
localized_objects: Mapped[List["PackLocalized"]] = relationship(
back_populates="parent", viewonly=True
)
class PackLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "packs_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType)
name: Mapped[Optional[str]]
description: Mapped[Optional[str]]
parent: Mapped[Pack] = relationship(viewonly=True)
class Song(ModelsV5Base, ReprHelper):
__tablename__ = "songs"
idx: Mapped[int]
id: Mapped[str] = mapped_column(primary_key=True)
title: Mapped[str]
artist: Mapped[str]
pack_id: Mapped[str] = mapped_column(
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
)
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[ArcaeaSongSide]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
pack: Mapped[Pack] = relationship(viewonly=True)
difficulties: Mapped[List["Difficulty"]] = relationship(
back_populates="song", viewonly=True
)
localized_objects: Mapped[List["SongLocalized"]] = relationship(
back_populates="parent", viewonly=True
)
@property
def charts_info(self):
return [d.chart_info for d in self.difficulties if d.chart_info is not None]
class SongLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "songs_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType)
title: Mapped[Optional[str]]
source: Mapped[Optional[str]]
parent: Mapped[Song] = relationship(
back_populates="localized_objects", viewonly=True
)
class SongSearchWord(ModelsV5Base, ReprHelper):
__tablename__ = "songs_search_words"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType)
type: Mapped[int] = mapped_column(comment="1: title, 2: artist")
value: Mapped[str]
class Difficulty(ModelsV5Base, ReprHelper):
__tablename__ = "difficulties"
song_id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(primary_key=True)
rating: Mapped[int]
rating_plus: Mapped[bool]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
audio_override: Mapped[bool] = mapped_column(default=False)
jacket_override: Mapped[bool] = mapped_column(default=False)
jacket_night: Mapped[Optional[str]]
title: Mapped[Optional[str]]
artist: Mapped[Optional[str]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
song: Mapped[Song] = relationship(back_populates="difficulties", viewonly=True)
chart_info: Mapped[Optional["ChartInfo"]] = relationship(
primaryjoin=(
"and_(Difficulty.song_id==ChartInfo.song_id, "
"Difficulty.rating_class==ChartInfo.rating_class)"
),
viewonly=True,
)
localized_objects: Mapped[List["DifficultyLocalized"]] = relationship(
primaryjoin=(
"and_(Difficulty.song_id==DifficultyLocalized.song_id, "
"Difficulty.rating_class==DifficultyLocalized.rating_class)"
),
viewonly=True,
)
class DifficultyLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "difficulties_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION")
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType)
title: Mapped[Optional[str]]
artist: Mapped[Optional[str]]
parent: Mapped[Difficulty] = relationship(
primaryjoin=and_(
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
),
viewonly=True,
)
class ChartInfo(ModelsV5Base, ReprHelper):
__tablename__ = "charts_info"
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
constant: Mapped[int] = mapped_column(
comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104."
)
notes: Mapped[Optional[int]]
difficulty: Mapped[Difficulty] = relationship(
primaryjoin=and_(
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
),
viewonly=True,
)
class Chart(ModelsV5ViewBase, ReprHelper):
__tablename__ = "charts"
song_idx: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass]
rating: Mapped[int]
rating_plus: Mapped[bool]
title: Mapped[str]
artist: Mapped[str]
pack_id: Mapped[str]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[int]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
audio_override: Mapped[bool]
jacket_override: Mapped[bool]
jacket_night: Mapped[Optional[str]]
constant: Mapped[int]
notes: Mapped[Optional[int]]
__table__ = create_view(
name=__tablename__,
selectable=select(
Song.idx.label("song_idx"),
Difficulty.song_id,
Difficulty.rating_class,
Difficulty.rating,
Difficulty.rating_plus,
func.coalesce(Difficulty.title, Song.title).label("title"),
func.coalesce(Difficulty.artist, Song.artist).label("artist"),
Song.pack_id,
func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"),
func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"),
Song.audio_preview,
Song.audio_preview_end,
Song.side,
func.coalesce(Difficulty.version, Song.version).label("version"),
func.coalesce(Difficulty.date, Song.date).label("date"),
func.coalesce(Difficulty.bg, Song.bg).label("bg"),
func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"),
Song.bg_day,
Song.bg_night,
Song.source,
Song.source_copyright,
Difficulty.chart_designer,
Difficulty.jacket_desginer,
Difficulty.audio_override,
Difficulty.jacket_override,
Difficulty.jacket_night,
ChartInfo.constant,
ChartInfo.notes,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(Song, Difficulty.song_id == Song.id),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
from arcaea_offline.database.models import (
PlayResultBest,
PlayResultCalculated,
)

View File

@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from arcaea_offline.constants.enums.arcaea import ArcaeaLanguage
from arcaea_offline.database.models.v5 import Difficulty, Pack, Song
from arcaea_offline.database.models import Difficulty, Pack, Song
from .definitions import ArcsongJsonDifficultyItem, ArcsongJsonRoot, ArcsongJsonSongItem
@ -38,6 +38,13 @@ class ArcsongJsonExporter:
else:
name_jp = ""
if difficulty.date is not None:
date = int(difficulty.date.timestamp())
elif song.date is not None:
date = int(song.date.timestamp())
else:
date = 0
return {
"name_en": difficulty.title or song.title,
"name_jp": name_jp,
@ -53,7 +60,7 @@ class ArcsongJsonExporter:
"world_unlock": False,
"remote_download": False,
"bg": difficulty.bg or song.bg or "",
"date": difficulty.date or song.date or 0,
"date": date,
"version": difficulty.version or song.version or "",
"difficulty": difficulty.rating * 2 + int(difficulty.rating_plus),
"rating": chart_info.constant or 0 if chart_info else 0,

View File

@ -1,6 +1,6 @@
from typing import List
from arcaea_offline.database.models.v5 import PlayResult
from arcaea_offline.database.models import PlayResult
from .definitions import (
ArcaeaOfflineDEFv2PlayResultItem,
@ -12,25 +12,27 @@ class ArcaeaOfflineDEFv2PlayResultExporter:
def export(self, items: List[PlayResult]) -> ArcaeaOfflineDEFv2PlayResultRoot:
export_items = []
for item in items:
export_item: ArcaeaOfflineDEFv2PlayResultItem = {}
export_item: ArcaeaOfflineDEFv2PlayResultItem = {
"id": item.id,
"songId": item.song_id,
"ratingClass": item.rating_class.value,
"score": item.score,
"pure": item.pure,
"far": item.far,
"lost": item.lost,
"date": int(item.date.timestamp() * 1000) if item.date else 0,
"maxRecall": item.max_recall,
"modifier": (
item.modifier.value if item.modifier is not None else None
),
"clearType": (
item.clear_type.value if item.clear_type is not None else None
),
"source": "https://arcaeaoffline.sevive.xyz/python",
"comment": item.comment,
}
export_item["id"] = item.id
export_item["songId"] = item.song_id
export_item["ratingClass"] = item.rating_class.value
export_item["score"] = item.score
export_item["pure"] = item.pure
export_item["far"] = item.far
export_item["lost"] = item.lost
export_item["date"] = item.date
export_item["maxRecall"] = item.max_recall
export_item["modifier"] = (
item.modifier.value if item.modifier is not None else None
)
export_item["clearType"] = (
item.clear_type.value if item.clear_type is not None else None
)
export_item["source"] = "https://arcaeaoffline.sevive.xyz/python"
export_item["comment"] = item.comment
export_items.append(export_item)
return {
"$schema": "https://arcaeaoffline.sevive.xyz/schemas/def/v2/score.schema.json",

View File

@ -4,7 +4,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
from arcaea_offline.database.models import (
ChartInfo,
Difficulty,
PlayResultBest,

View File

@ -3,6 +3,7 @@ packlist and songlist parsers
"""
import json
from datetime import datetime, timezone
from typing import List, Union
from arcaea_offline.constants.enums import (
@ -10,14 +11,13 @@ from arcaea_offline.constants.enums import (
ArcaeaRatingClass,
ArcaeaSongSide,
)
from arcaea_offline.database.models.v5 import (
from arcaea_offline.database.models import (
Difficulty,
DifficultyLocalized,
DifficultyLocalization,
Pack,
PackLocalized,
PackLocalization,
Song,
SongLocalized,
SongSearchWord,
SongLocalization,
)
@ -27,11 +27,11 @@ class ArcaeaListParser:
class ArcaeaPacklistParser(ArcaeaListParser):
def parse(self) -> List[Union[Pack, PackLocalized]]:
def parse(self) -> List[Union[Pack, PackLocalization]]:
root = json.loads(self.list_text)
packs = root["packs"]
results: List[Union[Pack, PackLocalized]] = [
results: List[Union[Pack, PackLocalization]] = [
Pack(id="single", name="Memory Archive")
]
for item in packs:
@ -48,7 +48,7 @@ class ArcaeaPacklistParser(ArcaeaListParser):
)
if name_localized or description_localized:
pack_localized = PackLocalized(id=pack.id)
pack_localized = PackLocalization(id=pack.id)
pack_localized.lang = key.value
pack_localized.name = name_localized
pack_localized.description = description_localized
@ -58,7 +58,7 @@ class ArcaeaPacklistParser(ArcaeaListParser):
class ArcaeaSonglistParser(ArcaeaListParser):
def parse_songs(self) -> List[Union[Song, SongLocalized, SongSearchWord]]:
def parse_songs(self) -> List[Union[Song, SongLocalization]]:
root = json.loads(self.list_text)
songs = root["songs"]
@ -72,11 +72,9 @@ class ArcaeaSonglistParser(ArcaeaListParser):
song.bpm = item["bpm"]
song.bpm_base = item["bpm_base"]
song.pack_id = item["set"]
song.audio_preview = item["audioPreview"]
song.audio_preview_end = item["audioPreviewEnd"]
song.side = ArcaeaSongSide(item["side"])
song.version = item["version"]
song.date = item["date"]
song.added_at = datetime.fromtimestamp(item["date"], tz=timezone.utc)
song.bg = item.get("bg")
song.bg_inverse = item.get("bg_inverse")
if item.get("bg_daynight"):
@ -95,32 +93,32 @@ class ArcaeaSonglistParser(ArcaeaListParser):
)
if title_localized or source_localized:
song_localized = SongLocalized(id=song.id)
song_localized = SongLocalization(id=song.id)
song_localized.lang = lang.value
song_localized.title = title_localized
song_localized.source = source_localized
results.append(song_localized)
# SongSearchTitle
search_titles = item.get("search_title", {}).get(lang.value, None)
if search_titles:
for search_title in search_titles:
song_search_word = SongSearchWord(
id=song.id, lang=lang.value, type=1, value=search_title
)
results.append(song_search_word)
# TODO: SongSearchTitle?
# search_titles = item.get("search_title", {}).get(lang.value, None)
# if search_titles:
# for search_title in search_titles:
# song_search_word = SongSearchWord(
# id=song.id, lang=lang.value, type=1, value=search_title
# )
# results.append(song_search_word)
search_artists = item.get("search_artist", {}).get(lang.value, None)
if search_artists:
for search_artist in search_artists:
song_search_word = SongSearchWord(
id=song.id, lang=lang.value, type=2, value=search_artist
)
results.append(song_search_word)
# search_artists = item.get("search_artist", {}).get(lang.value, None)
# if search_artists:
# for search_artist in search_artists:
# song_search_word = SongSearchWord(
# id=song.id, lang=lang.value, type=2, value=search_artist
# )
# results.append(song_search_word)
return results
def parse_difficulties(self) -> List[Union[Difficulty, DifficultyLocalized]]:
def parse_difficulties(self) -> List[Union[Difficulty, DifficultyLocalization]]:
root = json.loads(self.list_text)
songs = root["songs"]
@ -138,11 +136,11 @@ class ArcaeaSonglistParser(ArcaeaListParser):
difficulty.song_id = song["id"]
difficulty.rating_class = ArcaeaRatingClass(item["ratingClass"])
difficulty.rating = item["rating"]
difficulty.rating_plus = item.get("ratingPlus") or False
difficulty.is_rating_plus = item.get("ratingPlus") or False
difficulty.chart_designer = item["chartDesigner"]
difficulty.jacket_desginer = item.get("jacketDesigner") or None
difficulty.audio_override = item.get("audioOverride") or False
difficulty.jacket_override = item.get("jacketOverride") or False
difficulty.jacket_designer = item.get("jacketDesigner") or None
difficulty.has_overriding_audio = item.get("audioOverride") or False
difficulty.has_overriding_jacket = item.get("jacketOverride") or False
difficulty.jacket_night = item.get("jacketNight") or None
difficulty.title = item.get("title_localized", {}).get("en") or None
difficulty.artist = item.get("artist") or None
@ -151,7 +149,11 @@ class ArcaeaSonglistParser(ArcaeaListParser):
difficulty.bpm = item.get("bpm") or None
difficulty.bpm_base = item.get("bpm_base") or None
difficulty.version = item.get("version") or None
difficulty.date = item.get("date") or None
difficulty.added_at = (
datetime.fromtimestamp(item["date"], tz=timezone.utc)
if item.get("date") is not None
else None
)
results.append(difficulty)
for lang in ArcaeaLanguage:
@ -163,7 +165,7 @@ class ArcaeaSonglistParser(ArcaeaListParser):
)
if title_localized or artist_localized:
difficulty_localized = DifficultyLocalized(
difficulty_localized = DifficultyLocalization(
song_id=difficulty.song_id,
rating_class=difficulty.rating_class,
)

View File

@ -8,7 +8,7 @@ from arcaea_offline.constants.enums import (
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models.v5 import PlayResult
from arcaea_offline.database.models import PlayResult
from .common import fix_timestamp
@ -85,7 +85,7 @@ class ArcaeaOnlineApiParser:
play_result.pure = item["perfect_count"]
play_result.far = item["near_count"]
play_result.lost = item["miss_count"]
play_result.date = date
play_result.played_at = date
play_result.modifier = ArcaeaPlayResultModifier(item["modifier"])
play_result.clear_type = ArcaeaPlayResultClearType(item["clear_type"])

View File

@ -12,7 +12,7 @@ from arcaea_offline.constants.enums import (
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models.v5 import PlayResult
from arcaea_offline.database.models import PlayResult
from .common import fix_timestamp
@ -100,19 +100,18 @@ class ArcaeaSt3Parser:
else:
date = None
entities.append(
PlayResult(
song_id=song_id,
rating_class=rating_class_enum,
score=score,
pure=pure,
far=far,
lost=lost,
date=date,
modifier=modifier_enum,
clear_type=clear_type_enum,
comment=import_comment,
)
)
play_result = PlayResult()
play_result.song_id = song_id
play_result.rating_class = rating_class_enum
play_result.score = score
play_result.pure = pure
play_result.far = far
play_result.lost = lost
play_result.played_at = date
play_result.modifier = modifier_enum
play_result.clear_type = clear_type_enum
play_result.comment = import_comment
entities.append(play_result)
return entities

View File

@ -2,7 +2,7 @@ import sqlite3
from typing import List, overload
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import ChartInfo
from arcaea_offline.database.models import ChartInfo
class ArcsongDatabaseImporter:

View File

@ -3,7 +3,7 @@ from contextlib import closing
from typing import List, overload
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import ChartInfo
from arcaea_offline.database.models import ChartInfo
class ChartInfoDatabaseParser:

View File

@ -1,12 +0,0 @@
from typing import Generic, TypeVar
T = TypeVar("T")
class Singleton(type, Generic[T]):
_instance = None
def __call__(cls, *args, **kwargs) -> T:
if cls._instance is None:
cls._instance = super().__call__(*args, **kwargs)
return cls._instance

View File

@ -1,4 +1,4 @@
from typing import Any, Literal, overload
from typing import Any, Dict, Literal, overload
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
@ -10,8 +10,8 @@ from arcaea_offline.constants.play_result import ScoreLowerLimits
class PlayResultFormatter:
SCORE_GRADE_FORMAT_RESULTS = Literal["EX+", "EX", "AA", "A", "B", "C", "D"]
@staticmethod
def score_grade(score: int) -> SCORE_GRADE_FORMAT_RESULTS:
@classmethod
def score_grade(cls, score: int) -> SCORE_GRADE_FORMAT_RESULTS:
"""
Returns the score grade, e.g. EX+.
@ -20,23 +20,21 @@ class PlayResultFormatter:
if not isinstance(score, int):
raise TypeError(f"Unsupported type {type(score)}, cannot format")
if score >= ScoreLowerLimits.EX_PLUS:
return "EX+"
elif score >= ScoreLowerLimits.EX:
return "EX"
elif score >= ScoreLowerLimits.AA:
return "AA"
elif score >= ScoreLowerLimits.A:
return "A"
elif score >= ScoreLowerLimits.B:
return "B"
elif score >= ScoreLowerLimits.C:
return "C"
elif score >= ScoreLowerLimits.D:
return "D"
else:
if score < 0:
raise ValueError("score cannot be negative")
score_grades: Dict[int, Literal["EX+", "EX", "AA", "A", "B", "C", "D"]] = {
ScoreLowerLimits.EX_PLUS: "EX+",
ScoreLowerLimits.EX: "EX",
ScoreLowerLimits.AA: "AA",
ScoreLowerLimits.A: "A",
ScoreLowerLimits.B: "B",
ScoreLowerLimits.C: "C",
ScoreLowerLimits.D: "D",
}
return next(value for limit, value in score_grades.items() if score >= limit)
CLEAR_TYPE_FORMAT_RESULTS = Literal[
"TRACK LOST",
"NORMAL CLEAR",
@ -56,7 +54,6 @@ class PlayResultFormatter:
"""
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
"""
...
@overload
@classmethod
@ -69,7 +66,6 @@ class PlayResultFormatter:
Raises `ValueError` if the integer is negative.
"""
...
@overload
@classmethod
@ -77,7 +73,6 @@ class PlayResultFormatter:
"""
Returns "None"
"""
...
@classmethod
def clear_type(cls, clear_type: Any) -> CLEAR_TYPE_FORMAT_RESULTS:
@ -103,7 +98,6 @@ class PlayResultFormatter:
"""
Returns the uppercased clear type name, e.g. NORMAL CLEAR.
"""
...
@overload
@classmethod
@ -116,7 +110,6 @@ class PlayResultFormatter:
Raises `ValueError` if the integer is negative.
"""
...
@overload
@classmethod
@ -124,7 +117,6 @@ class PlayResultFormatter:
"""
Returns "None"
"""
...
@classmethod
def modifier(cls, modifier: Any) -> MODIFIER_FORMAT_RESULTS:

View File

@ -3,13 +3,9 @@ from enum import IntEnum
class KanaeDayNight(IntEnum):
Day = 0
Night = 1
DAY = 0
NIGHT = 1
def kanae_day_night(timestamp: int) -> KanaeDayNight:
"""
:param timestamp: POSIX timestamp, which is passed to `datetime.fromtimestamp(timestamp)`.
"""
dt = datetime.fromtimestamp(timestamp)
return KanaeDayNight.Day if 6 <= dt.hour <= 19 else KanaeDayNight.Night
@staticmethod
def from_datetime(dt: datetime) -> "KanaeDayNight":
return KanaeDayNight.DAY if 6 <= dt.hour <= 19 else KanaeDayNight.NIGHT # noqa: PLR2004

View File

@ -21,7 +21,8 @@ class TestPlayResultCalculators:
Decimal("-0.00")
) == Decimal("-31.67")
pytest.raises(ValueError, PlayResultCalculators.score_modifier, -1)
with pytest.raises(ValueError, match="negative"):
PlayResultCalculators.score_modifier(-1)
pytest.raises(TypeError, PlayResultCalculators.score_modifier, "9800000")
pytest.raises(TypeError, PlayResultCalculators.score_modifier, None)
@ -38,5 +39,8 @@ class TestPlayResultCalculators:
pytest.raises(TypeError, PlayResultCalculators.play_rating, 10002221, None)
pytest.raises(ValueError, PlayResultCalculators.play_rating, -1, 120)
pytest.raises(ValueError, PlayResultCalculators.play_rating, 10002221, -1)
with pytest.raises(ValueError, match="negative"):
PlayResultCalculators.play_rating(-1, 120)
with pytest.raises(ValueError, match="negative"):
PlayResultCalculators.play_rating(10002221, -1)

View File

@ -14,7 +14,7 @@ def db_conn():
conn.close()
@pytest.fixture()
@pytest.fixture
def db_session(db_conn):
session = Session(bind=db_conn)
yield session

View File

@ -10,11 +10,13 @@ Database model v5 common relationships
"""
from datetime import datetime, timezone
from arcaea_offline.constants.enums import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
from arcaea_offline.database.models import (
ChartInfo,
Difficulty,
ModelsV5Base,
ModelBase,
Pack,
PlayResult,
Song,
@ -24,7 +26,7 @@ from arcaea_offline.database.models.v5 import (
class TestSongRelationships:
@staticmethod
def init_db(session):
ModelsV5Base.metadata.create_all(session.bind)
ModelBase.metadata.create_all(session.bind)
def test_relationships(self, db_session):
self.init_db(db_session)
@ -45,52 +47,56 @@ class TestSongRelationships:
title=title_en,
artist=artist_en,
pack_id=pack.id,
added_at=datetime(2024, 7, 5, tzinfo=timezone.utc),
)
difficulty_pst = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.PAST,
rating=2,
rating_plus=False,
is_rating_plus=False,
)
chart_info_pst = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.PAST,
constant=20,
notes=200,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
)
difficulty_prs = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT,
rating=7,
rating_plus=True,
is_rating_plus=True,
)
chart_info_prs = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT,
constant=78,
notes=780,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
)
difficulty_ftr = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
rating=10,
rating_plus=True,
is_rating_plus=True,
)
chart_info_ftr = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
constant=109,
notes=1090,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
)
difficulty_etr = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.ETERNAL,
rating=9,
rating_plus=True,
is_rating_plus=True,
)
play_result_ftr = PlayResult(
@ -124,20 +130,19 @@ class TestSongRelationships:
difficulty_ftr,
difficulty_etr,
]
assert song.charts_info == [chart_info_pst, chart_info_prs, chart_info_ftr]
assert difficulty_pst.song == song
assert difficulty_prs.song == song
assert difficulty_ftr.song == song
assert difficulty_etr.song == song
assert difficulty_pst.chart_info == chart_info_pst
assert difficulty_prs.chart_info == chart_info_prs
assert difficulty_ftr.chart_info == chart_info_ftr
assert difficulty_etr.chart_info is None
assert difficulty_pst.chart_info_list == [chart_info_pst]
assert difficulty_prs.chart_info_list == [chart_info_prs]
assert difficulty_ftr.chart_info_list == [chart_info_ftr]
assert difficulty_etr.chart_info_list == []
assert chart_info_pst.difficulty == difficulty_pst
assert chart_info_prs.difficulty == difficulty_prs
assert chart_info_ftr.difficulty == difficulty_ftr
assert play_result_ftr.difficulty == difficulty_ftr
# assert play_result_ftr.difficulty == difficulty_ftr

View File

@ -5,13 +5,13 @@ Pack <> PackLocalized
"""
from arcaea_offline.constants.enums import ArcaeaLanguage
from arcaea_offline.database.models.v5 import ModelsV5Base, Pack, PackLocalized
from arcaea_offline.database.models import ModelBase, Pack, PackLocalization
class TestPackRelationships:
@staticmethod
def init_db(session):
ModelsV5Base.metadata.create_all(session.bind)
ModelBase.metadata.create_all(session.bind)
def test_localized_objects(self, db_session):
self.init_db(db_session)
@ -26,20 +26,16 @@ class TestPackRelationships:
description=description_en,
)
pkid_ja = 1
description_ja = "普通のパートナー「∅」と一緒に、\n不人気フレームワーク「Arcaea Offline」より、\n一般的なデータベース・モデルを通過する。"
pack_localized_ja = PackLocalized(
pkid=pkid_ja,
pack_localized_ja = PackLocalization(
id=pack_id,
lang=ArcaeaLanguage.JA.value,
name=None,
description=description_ja,
)
pkid_zh_hans = 2
description_zh_hans = "与平凡的「∅」一起,\n在没人用的「Arcaea Offline」框架里\n一同探索随处可见的数据库模型。"
pack_localized_zh_hans = PackLocalized(
pkid=pkid_zh_hans,
pack_localized_zh_hans = PackLocalization(
id=pack_id,
lang=ArcaeaLanguage.ZH_HANS.value,
name=None,
@ -49,11 +45,11 @@ class TestPackRelationships:
db_session.add_all([pack, pack_localized_ja])
db_session.commit()
assert len(pack.localized_objects) == len([pack_localized_ja])
assert len(pack.localized_entries) == len([pack_localized_ja])
assert pack_localized_ja.parent.description == pack.description
assert pack_localized_ja.pack.description == pack.description
# relationships should be viewonly
# test back populates
new_pack = Pack(
id=f"{pack_id}_new",
name="NEW",
@ -61,9 +57,10 @@ class TestPackRelationships:
)
db_session.add(new_pack)
pack_localized_ja.parent = new_pack
pack.localized_objects.append(pack_localized_zh_hans)
pack_localized_ja.pack = new_pack
pack.localized_entries.append(pack_localized_zh_hans)
db_session.commit()
assert pack_localized_ja.parent == pack
assert len(pack.localized_objects) == 1
assert pack_localized_ja.pack.id == new_pack.id
# TODO: this failes but why
assert len(pack.localized_entries) == 2

View File

@ -6,13 +6,15 @@ Chart functionalities
- Difficulty song info overriding
"""
from datetime import datetime, timezone
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
from arcaea_offline.database.models import (
Chart,
ChartInfo,
Difficulty,
ModelsV5Base,
ModelsV5ViewBase,
ModelBase,
ModelViewBase,
Pack,
Song,
)
@ -20,8 +22,8 @@ from arcaea_offline.database.models.v5 import (
class TestChart:
def init_db(self, session):
ModelsV5Base.metadata.create_all(session.bind)
ModelsV5ViewBase.metadata.create_all(session.bind)
ModelBase.metadata.create_all(session.bind)
ModelViewBase.metadata.create_all(session.bind)
def test_basic(self, db_session):
self.init_db(db_session)
@ -37,20 +39,23 @@ class TestChart:
title="~TEST~",
artist="~test~",
pack_id=pack_id,
added_at=datetime(2024, 7, 4, tzinfo=timezone.utc),
)
difficulty = Difficulty(
song_id=song_id,
rating_class=rating_class,
rating=9,
rating_plus=True,
is_rating_plus=True,
)
chart_info = ChartInfo(
song_id=song_id,
rating_class=rating_class,
constant=98,
notes=980,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
)
db_session.add_all([pack, song, difficulty, chart_info])
db_session.commit()
chart: Chart = (
db_session.query(Chart)
@ -64,7 +69,7 @@ class TestChart:
assert chart.artist == song.artist
assert chart.pack_id == song.pack_id
assert chart.rating == difficulty.rating
assert chart.rating_plus == difficulty.rating_plus
assert chart.is_rating_plus == difficulty.is_rating_plus
assert chart.constant == chart_info.constant
assert chart.notes == chart_info.notes
@ -82,12 +87,13 @@ class TestChart:
title="~TEST~",
artist="~test~",
pack_id=pack_id,
added_at=datetime(2024, 7, 4, tzinfo=timezone.utc),
)
difficulty = Difficulty(
song_id=song_id,
rating_class=rating_class,
rating=9,
rating_plus=True,
is_rating_plus=True,
title="~TEST DIFF~",
artist="~diff~",
)
@ -96,8 +102,10 @@ class TestChart:
rating_class=rating_class,
constant=98,
notes=980,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
)
db_session.add_all([pack, song, difficulty, chart_info])
db_session.commit()
chart: Chart = (
db_session.query(Chart)

View File

@ -2,10 +2,9 @@ from datetime import datetime, timedelta, timezone
from enum import IntEnum
from typing import Optional
from sqlalchemy import text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from arcaea_offline.database.models._custom_types import DbIntEnum, TZDateTime
from arcaea_offline.database.models._types import ForceTimezoneDateTime
class TestIntEnum(IntEnum):
@ -22,43 +21,19 @@ class TestBase(DeclarativeBase):
id: Mapped[int] = mapped_column(primary_key=True)
class IntEnumTestModel(TestBase):
__tablename__ = "test_int_enum"
value: Mapped[Optional[TestIntEnum]] = mapped_column(DbIntEnum(TestIntEnum))
class TZDatetimeTestModel(TestBase):
class ForceTimezoneDatetimeTestModel(TestBase):
__tablename__ = "test_tz_datetime"
value: Mapped[Optional[datetime]] = mapped_column(TZDateTime)
value: Mapped[Optional[datetime]] = mapped_column(ForceTimezoneDateTime)
class TestCustomTypes:
def test_int_enum(self, db_session):
def _query_value(_id: int):
return db_session.execute(
text(
f"SELECT value FROM {IntEnumTestModel.__tablename__} WHERE id = {_id}"
)
).one()[0]
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
basic_obj = IntEnumTestModel(id=1, value=TestIntEnum.TWO)
null_obj = IntEnumTestModel(id=2, value=None)
db_session.add(basic_obj)
db_session.add(null_obj)
db_session.commit()
assert _query_value(1) == TestIntEnum.TWO.value
assert _query_value(2) is None
def test_tz_datetime(self, db_session):
def test_force_timezone_datetime(self, db_session):
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
dt1 = datetime.now(tz=timezone(timedelta(hours=8)))
basic_obj = TZDatetimeTestModel(id=1, value=dt1)
null_obj = TZDatetimeTestModel(id=2, value=None)
basic_obj = ForceTimezoneDatetimeTestModel(id=1, value=dt1)
null_obj = ForceTimezoneDatetimeTestModel(id=2, value=None)
db_session.add(basic_obj)
db_session.add(null_obj)
db_session.commit()

View File

@ -1,108 +0,0 @@
from arcaea_offline.database.models.v4.songs import (
Chart,
ChartInfo,
Difficulty,
Pack,
Song,
SongsBase,
SongsViewBase,
)
def _song(**kw):
defaults = {"artist": "test"}
defaults.update(kw)
return Song(**defaults)
def _difficulty(**kw):
defaults = {"rating_plus": False, "audio_override": False, "jacket_override": False}
defaults.update(kw)
return Difficulty(**defaults)
class Test_Chart:
def init_db(self, session):
SongsBase.metadata.create_all(session.bind, checkfirst=False)
SongsViewBase.metadata.create_all(session.bind, checkfirst=False)
def test_chart_info(self, db_session):
self.init_db(db_session)
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="song0", set="test", title="Full Chart Info"),
_song(idx=1, id="song1", set="test", title="Partial Chart Info"),
_song(idx=2, id="song2", set="test", title="No Chart Info"),
_difficulty(song_id="song0", rating_class=2, rating=9),
_difficulty(song_id="song1", rating_class=2, rating=9),
_difficulty(song_id="song2", rating_class=2, rating=9),
ChartInfo(song_id="song0", rating_class=2, constant=90, notes=1234),
ChartInfo(song_id="song1", rating_class=2, constant=90),
]
db_session.add_all(pre_entites)
db_session.commit()
chart_song0_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song0") & (Chart.rating_class == 2))
.one()
)
assert chart_song0_ratingclass2.constant == 90
assert chart_song0_ratingclass2.notes == 1234
chart_song1_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song1") & (Chart.rating_class == 2))
.one()
)
assert chart_song1_ratingclass2.constant == 90
assert chart_song1_ratingclass2.notes is None
chart_song2_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song2") & (Chart.rating_class == 2))
.first()
)
assert chart_song2_ratingclass2 is None
def test_difficulty_title_override(self, db_session):
self.init_db(db_session)
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="test", set="test", title="Test"),
_difficulty(song_id="test", rating_class=0, rating=2),
_difficulty(song_id="test", rating_class=1, rating=5),
_difficulty(song_id="test", rating_class=2, rating=8),
_difficulty(
song_id="test", rating_class=3, rating=10, title="TEST ~REVIVE~"
),
ChartInfo(song_id="test", rating_class=0, constant=10),
ChartInfo(song_id="test", rating_class=1, constant=10),
ChartInfo(song_id="test", rating_class=2, constant=10),
ChartInfo(song_id="test", rating_class=3, constant=10),
]
db_session.add_all(pre_entites)
db_session.commit()
charts_original_title = (
db_session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class in [0, 1, 2]))
.all()
)
assert all(chart.title == "Test" for chart in charts_original_title)
chart_overrided_title = (
db_session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class == 3))
.one()
)
assert chart_overrided_title.title == "TEST ~REVIVE~"

View File

@ -6,7 +6,7 @@ from arcaea_offline.constants.enums.arcaea import (
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models.v5.play_results import PlayResult
from arcaea_offline.database.models.play_result import PlayResult
from arcaea_offline.external.importers.arcaea.online import ArcaeaOnlineApiParser
API_RESULT = {
@ -80,9 +80,9 @@ class TestArcaeaOnlineApiParser:
assert test1.pure == 1234
assert test1.far == 12
assert test1.lost == 4
assert test1.date == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
assert test1.played_at == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
assert test1.clear_type is ArcaeaPlayResultClearType.NORMAL_CLEAR
assert test1.modifier is ArcaeaPlayResultModifier.NORMAL
test2 = next(filter(lambda x: x.song_id == "test2", play_results))
assert test2.date == datetime(2024, 1, 2, 0, 0, 0, tzinfo=timezone.utc)
assert test2.played_at == datetime(2024, 1, 2, 0, 0, 0, tzinfo=timezone.utc)

View File

@ -31,7 +31,7 @@ class TestArcaeaSt3Parser:
assert test1.pure == 895
assert test1.far == 32
assert test1.lost == 22
assert test1.date == datetime.fromtimestamp(1722100000).astimezone()
assert test1.played_at == datetime.fromtimestamp(1722100000).astimezone()
assert test1.clear_type is ArcaeaPlayResultClearType.TRACK_LOST
assert test1.modifier is ArcaeaPlayResultModifier.HARD
@ -48,7 +48,7 @@ class TestArcaeaSt3Parser:
assert corrupt2.modifier is None
date1 = next(filter(lambda x: x.song_id == "date1", play_results))
assert date1.date is None
assert date1.played_at is None
def test_invalid_input(self):
pytest.raises(TypeError, ArcaeaSt3Parser.parse, "abcdefghijklmn")

View File

@ -2,7 +2,7 @@ import sqlite3
import tests.resources
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import ChartInfo
from arcaea_offline.database.models import ChartInfo
from arcaea_offline.external.importers.arcsong import (
ArcsongDatabaseImporter,
)

View File

@ -2,7 +2,7 @@ import sqlite3
import tests.resources
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import ChartInfo
from arcaea_offline.database.models import ChartInfo
from arcaea_offline.external.importers.chart_info_database import (
ChartInfoDatabaseParser,
)

View File

@ -73,7 +73,9 @@ class TestPlayResultFormatter:
assert PlayResultFormatter.score_grade(5500000) == "D"
assert PlayResultFormatter.score_grade(0) == "D"
pytest.raises(ValueError, PlayResultFormatter.score_grade, -1)
with pytest.raises(ValueError, match="negative"):
PlayResultFormatter.score_grade(-1)
pytest.raises(TypeError, PlayResultFormatter.score_grade, "10001284")
pytest.raises(TypeError, PlayResultFormatter.score_grade, [])
pytest.raises(TypeError, PlayResultFormatter.score_grade, None)
@ -108,7 +110,9 @@ class TestPlayResultFormatter:
assert PlayResultFormatter.clear_type(1) == "NORMAL CLEAR"
assert PlayResultFormatter.clear_type(6) == "UNKNOWN"
pytest.raises(ValueError, PlayResultFormatter.clear_type, -1)
with pytest.raises(ValueError, match="negative"):
PlayResultFormatter.clear_type(-1)
pytest.raises(TypeError, PlayResultFormatter.clear_type, "1")
pytest.raises(TypeError, PlayResultFormatter.clear_type, [])
@ -121,6 +125,8 @@ class TestPlayResultFormatter:
assert PlayResultFormatter.modifier(1) == "EASY"
assert PlayResultFormatter.modifier(6) == "UNKNOWN"
pytest.raises(ValueError, PlayResultFormatter.modifier, -1)
with pytest.raises(ValueError, match="negative"):
PlayResultFormatter.modifier(-1)
pytest.raises(TypeError, PlayResultFormatter.modifier, "1")
pytest.raises(TypeError, PlayResultFormatter.modifier, [])