9 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
31 changed files with 1492 additions and 545 deletions

View File

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

View File

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

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

@ -1,38 +1,32 @@
from .arcaea import ( from ._base import ModelBase, ModelViewBase
Chart, from .chart_info import ChartInfo
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongSearchWord,
)
from .base import ModelsV5Base, ModelsV5ViewBase
from .config import Property from .config import Property
from .play_results import ( 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, CalculatedPotential,
PlayResult, PlayResult,
PlayResultBest, PlayResultBest,
PlayResultCalculated, PlayResultCalculated,
) ) # isort: skip
__all__ = [ __all__ = [
"CalculatedPotential", "CalculatedPotential",
"Chart", "Chart",
"ChartInfo", "ChartInfo",
"Difficulty", "Difficulty",
"DifficultyLocalized", "DifficultyLocalization",
"ModelsV5Base", "ModelBase",
"ModelsV5ViewBase", "ModelViewBase",
"Pack", "Pack",
"PackLocalized", "PackLocalization",
"PlayResult", "PlayResult",
"PlayResultBest", "PlayResultBest",
"PlayResultCalculated", "PlayResultCalculated",
"Property", "Property",
"Song", "Song",
"SongLocalized", "SongLocalization",
"SongSearchWord",
] ]

View File

@ -1,31 +1,30 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.exc import DetachedInstanceError from sqlalchemy.orm.exc import DetachedInstanceError
from arcaea_offline.constants.enums import ( from ._types import ForceTimezoneDateTime
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from ._custom_types import DbIntEnum, TZDateTime
TYPE_ANNOTATION_MAP = { TYPE_ANNOTATION_MAP = {
datetime: TZDateTime, datetime: ForceTimezoneDateTime,
ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass),
ArcaeaSongSide: DbIntEnum(ArcaeaSongSide),
ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType),
ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier),
} }
class ModelsV5Base(DeclarativeBase): class ModelBase(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP 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 type_annotation_map = TYPE_ANNOTATION_MAP
@ -34,7 +33,7 @@ class ReprHelper:
def _repr(self, **kwargs) -> str: def _repr(self, **kwargs) -> str:
""" """
Helper for __repr__ SQLAlchemy model __repr__ helper
https://stackoverflow.com/a/55749579/16484891 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

@ -1,285 +0,0 @@
from datetime import datetime
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[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]]
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[datetime]]
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[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]]
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

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

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

View File

@ -85,7 +85,7 @@ class ArcaeaOnlineApiParser:
play_result.pure = item["perfect_count"] play_result.pure = item["perfect_count"]
play_result.far = item["near_count"] play_result.far = item["near_count"]
play_result.lost = item["miss_count"] play_result.lost = item["miss_count"]
play_result.date = date play_result.played_at = date
play_result.modifier = ArcaeaPlayResultModifier(item["modifier"]) play_result.modifier = ArcaeaPlayResultModifier(item["modifier"])
play_result.clear_type = ArcaeaPlayResultClearType(item["clear_type"]) play_result.clear_type = ArcaeaPlayResultClearType(item["clear_type"])

View File

@ -100,19 +100,18 @@ class ArcaeaSt3Parser:
else: else:
date = None date = None
entities.append( play_result = PlayResult()
PlayResult( play_result.song_id = song_id
song_id=song_id, play_result.rating_class = rating_class_enum
rating_class=rating_class_enum, play_result.score = score
score=score, play_result.pure = pure
pure=pure, play_result.far = far
far=far, play_result.lost = lost
lost=lost, play_result.played_at = date
date=date, play_result.modifier = modifier_enum
modifier=modifier_enum, play_result.clear_type = clear_type_enum
clear_type=clear_type_enum, play_result.comment = import_comment
comment=import_comment,
) entities.append(play_result)
)
return entities return entities

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.constants.enums import ArcaeaRatingClass
from arcaea_offline.database.models import ( from arcaea_offline.database.models import (
ChartInfo, ChartInfo,
Difficulty, Difficulty,
ModelsV5Base, ModelBase,
Pack, Pack,
PlayResult, PlayResult,
Song, Song,
@ -24,7 +26,7 @@ from arcaea_offline.database.models import (
class TestSongRelationships: class TestSongRelationships:
@staticmethod @staticmethod
def init_db(session): def init_db(session):
ModelsV5Base.metadata.create_all(session.bind) ModelBase.metadata.create_all(session.bind)
def test_relationships(self, db_session): def test_relationships(self, db_session):
self.init_db(db_session) self.init_db(db_session)
@ -45,52 +47,56 @@ class TestSongRelationships:
title=title_en, title=title_en,
artist=artist_en, artist=artist_en,
pack_id=pack.id, pack_id=pack.id,
added_at=datetime(2024, 7, 5, tzinfo=timezone.utc),
) )
difficulty_pst = Difficulty( difficulty_pst = Difficulty(
song_id=song.id, song_id=song.id,
rating_class=ArcaeaRatingClass.PAST, rating_class=ArcaeaRatingClass.PAST,
rating=2, rating=2,
rating_plus=False, is_rating_plus=False,
) )
chart_info_pst = ChartInfo( chart_info_pst = ChartInfo(
song_id=song.id, song_id=song.id,
rating_class=ArcaeaRatingClass.PAST, rating_class=ArcaeaRatingClass.PAST,
constant=20, constant=20,
notes=200, notes=200,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
) )
difficulty_prs = Difficulty( difficulty_prs = Difficulty(
song_id=song.id, song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT, rating_class=ArcaeaRatingClass.PRESENT,
rating=7, rating=7,
rating_plus=True, is_rating_plus=True,
) )
chart_info_prs = ChartInfo( chart_info_prs = ChartInfo(
song_id=song.id, song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT, rating_class=ArcaeaRatingClass.PRESENT,
constant=78, constant=78,
notes=780, notes=780,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
) )
difficulty_ftr = Difficulty( difficulty_ftr = Difficulty(
song_id=song.id, song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE, rating_class=ArcaeaRatingClass.FUTURE,
rating=10, rating=10,
rating_plus=True, is_rating_plus=True,
) )
chart_info_ftr = ChartInfo( chart_info_ftr = ChartInfo(
song_id=song.id, song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE, rating_class=ArcaeaRatingClass.FUTURE,
constant=109, constant=109,
notes=1090, notes=1090,
added_at=datetime(2024, 7, 12, tzinfo=timezone.utc),
) )
difficulty_etr = Difficulty( difficulty_etr = Difficulty(
song_id=song.id, song_id=song.id,
rating_class=ArcaeaRatingClass.ETERNAL, rating_class=ArcaeaRatingClass.ETERNAL,
rating=9, rating=9,
rating_plus=True, is_rating_plus=True,
) )
play_result_ftr = PlayResult( play_result_ftr = PlayResult(
@ -124,20 +130,19 @@ class TestSongRelationships:
difficulty_ftr, difficulty_ftr,
difficulty_etr, difficulty_etr,
] ]
assert song.charts_info == [chart_info_pst, chart_info_prs, chart_info_ftr]
assert difficulty_pst.song == song assert difficulty_pst.song == song
assert difficulty_prs.song == song assert difficulty_prs.song == song
assert difficulty_ftr.song == song assert difficulty_ftr.song == song
assert difficulty_etr.song == song assert difficulty_etr.song == song
assert difficulty_pst.chart_info == chart_info_pst assert difficulty_pst.chart_info_list == [chart_info_pst]
assert difficulty_prs.chart_info == chart_info_prs assert difficulty_prs.chart_info_list == [chart_info_prs]
assert difficulty_ftr.chart_info == chart_info_ftr assert difficulty_ftr.chart_info_list == [chart_info_ftr]
assert difficulty_etr.chart_info is None assert difficulty_etr.chart_info_list == []
assert chart_info_pst.difficulty == difficulty_pst assert chart_info_pst.difficulty == difficulty_pst
assert chart_info_prs.difficulty == difficulty_prs assert chart_info_prs.difficulty == difficulty_prs
assert chart_info_ftr.difficulty == difficulty_ftr 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.constants.enums import ArcaeaLanguage
from arcaea_offline.database.models import ModelsV5Base, Pack, PackLocalized from arcaea_offline.database.models import ModelBase, Pack, PackLocalization
class TestPackRelationships: class TestPackRelationships:
@staticmethod @staticmethod
def init_db(session): def init_db(session):
ModelsV5Base.metadata.create_all(session.bind) ModelBase.metadata.create_all(session.bind)
def test_localized_objects(self, db_session): def test_localized_objects(self, db_session):
self.init_db(db_session) self.init_db(db_session)
@ -26,20 +26,16 @@ class TestPackRelationships:
description=description_en, description=description_en,
) )
pkid_ja = 1
description_ja = "普通のパートナー「∅」と一緒に、\n不人気フレームワーク「Arcaea Offline」より、\n一般的なデータベース・モデルを通過する。" description_ja = "普通のパートナー「∅」と一緒に、\n不人気フレームワーク「Arcaea Offline」より、\n一般的なデータベース・モデルを通過する。"
pack_localized_ja = PackLocalized( pack_localized_ja = PackLocalization(
pkid=pkid_ja,
id=pack_id, id=pack_id,
lang=ArcaeaLanguage.JA.value, lang=ArcaeaLanguage.JA.value,
name=None, name=None,
description=description_ja, description=description_ja,
) )
pkid_zh_hans = 2
description_zh_hans = "与平凡的「∅」一起,\n在没人用的「Arcaea Offline」框架里\n一同探索随处可见的数据库模型。" description_zh_hans = "与平凡的「∅」一起,\n在没人用的「Arcaea Offline」框架里\n一同探索随处可见的数据库模型。"
pack_localized_zh_hans = PackLocalized( pack_localized_zh_hans = PackLocalization(
pkid=pkid_zh_hans,
id=pack_id, id=pack_id,
lang=ArcaeaLanguage.ZH_HANS.value, lang=ArcaeaLanguage.ZH_HANS.value,
name=None, name=None,
@ -49,11 +45,11 @@ class TestPackRelationships:
db_session.add_all([pack, pack_localized_ja]) db_session.add_all([pack, pack_localized_ja])
db_session.commit() 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( new_pack = Pack(
id=f"{pack_id}_new", id=f"{pack_id}_new",
name="NEW", name="NEW",
@ -61,9 +57,10 @@ class TestPackRelationships:
) )
db_session.add(new_pack) db_session.add(new_pack)
pack_localized_ja.parent = new_pack pack_localized_ja.pack = new_pack
pack.localized_objects.append(pack_localized_zh_hans) pack.localized_entries.append(pack_localized_zh_hans)
db_session.commit() db_session.commit()
assert pack_localized_ja.parent == pack assert pack_localized_ja.pack.id == new_pack.id
assert len(pack.localized_objects) == 1 # TODO: this failes but why
assert len(pack.localized_entries) == 2

View File

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

View File

@ -2,10 +2,9 @@ from datetime import datetime, timedelta, timezone
from enum import IntEnum from enum import IntEnum
from typing import Optional from typing import Optional
from sqlalchemy import text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 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): class TestIntEnum(IntEnum):
@ -22,43 +21,19 @@ class TestBase(DeclarativeBase):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
class IntEnumTestModel(TestBase): class ForceTimezoneDatetimeTestModel(TestBase):
__tablename__ = "test_int_enum"
value: Mapped[Optional[TestIntEnum]] = mapped_column(DbIntEnum(TestIntEnum))
class TZDatetimeTestModel(TestBase):
__tablename__ = "test_tz_datetime" __tablename__ = "test_tz_datetime"
value: Mapped[Optional[datetime]] = mapped_column(TZDateTime) value: Mapped[Optional[datetime]] = mapped_column(ForceTimezoneDateTime)
class TestCustomTypes: class TestCustomTypes:
def test_int_enum(self, db_session): def test_force_timezone_datetime(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):
TestBase.metadata.create_all(db_session.bind, checkfirst=False) TestBase.metadata.create_all(db_session.bind, checkfirst=False)
dt1 = datetime.now(tz=timezone(timedelta(hours=8))) dt1 = datetime.now(tz=timezone(timedelta(hours=8)))
basic_obj = TZDatetimeTestModel(id=1, value=dt1) basic_obj = ForceTimezoneDatetimeTestModel(id=1, value=dt1)
null_obj = TZDatetimeTestModel(id=2, value=None) null_obj = ForceTimezoneDatetimeTestModel(id=2, value=None)
db_session.add(basic_obj) db_session.add(basic_obj)
db_session.add(null_obj) db_session.add(null_obj)
db_session.commit() db_session.commit()

View File

@ -6,7 +6,7 @@ from arcaea_offline.constants.enums.arcaea import (
ArcaeaPlayResultModifier, ArcaeaPlayResultModifier,
ArcaeaRatingClass, ArcaeaRatingClass,
) )
from arcaea_offline.database.models.play_results import PlayResult from arcaea_offline.database.models.play_result import PlayResult
from arcaea_offline.external.importers.arcaea.online import ArcaeaOnlineApiParser from arcaea_offline.external.importers.arcaea.online import ArcaeaOnlineApiParser
API_RESULT = { API_RESULT = {
@ -80,9 +80,9 @@ class TestArcaeaOnlineApiParser:
assert test1.pure == 1234 assert test1.pure == 1234
assert test1.far == 12 assert test1.far == 12
assert test1.lost == 4 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.clear_type is ArcaeaPlayResultClearType.NORMAL_CLEAR
assert test1.modifier is ArcaeaPlayResultModifier.NORMAL assert test1.modifier is ArcaeaPlayResultModifier.NORMAL
test2 = next(filter(lambda x: x.song_id == "test2", play_results)) 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.pure == 895
assert test1.far == 32 assert test1.far == 32
assert test1.lost == 22 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.clear_type is ArcaeaPlayResultClearType.TRACK_LOST
assert test1.modifier is ArcaeaPlayResultModifier.HARD assert test1.modifier is ArcaeaPlayResultModifier.HARD
@ -48,7 +48,7 @@ class TestArcaeaSt3Parser:
assert corrupt2.modifier is None assert corrupt2.modifier is None
date1 = next(filter(lambda x: x.song_id == "date1", play_results)) 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): def test_invalid_input(self):
pytest.raises(TypeError, ArcaeaSt3Parser.parse, "abcdefghijklmn") pytest.raises(TypeError, ArcaeaSt3Parser.parse, "abcdefghijklmn")