8 Commits

16 changed files with 247 additions and 12 deletions

View File

@ -1,5 +1,6 @@
from .arcaea import ( from .arcaea import (
ArcaeaLanguage, ArcaeaLanguage,
ArcaeaLocalizationLanguage,
ArcaeaPlayResultClearType, ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier, ArcaeaPlayResultModifier,
ArcaeaRatingClass, ArcaeaRatingClass,
@ -8,6 +9,7 @@ from .arcaea import (
__all__ = [ __all__ = [
"ArcaeaLanguage", "ArcaeaLanguage",
"ArcaeaLocalizationLanguage",
"ArcaeaPlayResultClearType", "ArcaeaPlayResultClearType",
"ArcaeaPlayResultModifier", "ArcaeaPlayResultModifier",
"ArcaeaRatingClass", "ArcaeaRatingClass",

View File

@ -31,6 +31,13 @@ class ArcaeaPlayResultClearType(IntEnum):
EASY_CLEAR = 5 EASY_CLEAR = 5
class ArcaeaLocalizationLanguage(Enum):
JA = "ja"
KO = "ko"
ZH_HANT = "zh-Hant"
ZH_HANS = "zh-Hans"
class ArcaeaLanguage(Enum): class ArcaeaLanguage(Enum):
EN = "en" EN = "en"
JA = "ja" JA = "ja"

View File

@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)} revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)} down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}

View File

@ -15,7 +15,6 @@ from alembic import context, op
from arcaea_offline.database.migrations.legacies.v5 import ForceTimezoneDateTime from arcaea_offline.database.migrations.legacies.v5 import ForceTimezoneDateTime
# revision identifiers, used by Alembic.
revision: str = "0ca6733e40dc" revision: str = "0ca6733e40dc"
down_revision: Union[str, None] = "a3f9d48b7de3" down_revision: Union[str, None] = "a3f9d48b7de3"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
@ -27,7 +26,7 @@ def upgrade(
data_migration: bool = True, data_migration: bool = True,
data_migration_options: Any = None, data_migration_options: Any = None,
) -> None: ) -> None:
op.create_table( property_tbl = op.create_table(
"property", "property",
sa.Column("key", sa.String(), nullable=False), sa.Column("key", sa.String(), nullable=False),
sa.Column("value", sa.String(), nullable=False), sa.Column("value", sa.String(), nullable=False),
@ -295,6 +294,8 @@ def upgrade(
op.drop_table("scores_old") op.drop_table("scores_old")
op.execute(sa.insert(property_tbl).values(key="version", value="5"))
def downgrade() -> None: def downgrade() -> None:
raise NotImplementedError( raise NotImplementedError(

View File

@ -1,7 +1,7 @@
"""v1 to v4 """v1 to v4
Revision ID: a3f9d48b7de3 Revision ID: a3f9d48b7de3
Revises: Revises: b7a0accfc17f
Create Date: 2024-11-24 00:03:07.697165 Create Date: 2024-11-24 00:03:07.697165
""" """
@ -12,9 +12,8 @@ from typing import Mapping, Optional, Sequence, TypedDict, Union
import sqlalchemy as sa import sqlalchemy as sa
from alembic import context, op from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "a3f9d48b7de3" revision: str = "a3f9d48b7de3"
down_revision: Union[str, None] = None down_revision: Union[str, None] = "b7a0accfc17f"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -268,6 +267,16 @@ def upgrade(
op.drop_table("scores_old") op.drop_table("scores_old")
op.drop_table("properties", if_exists=True)
properties_tbl = op.create_table(
"properties",
sa.Column("key", sa.TEXT(), nullable=False),
sa.Column("value", sa.TEXT(), nullable=False),
sa.PrimaryKeyConstraint("key", name="pk_properties"),
)
op.execute(sa.insert(properties_tbl).values(key="version", value="4"))
def downgrade() -> None: def downgrade() -> None:
raise NotImplementedError( raise NotImplementedError(

View File

@ -0,0 +1,106 @@
"""v1
Revision ID: b7a0accfc17f
Revises:
Create Date: 2025-06-05 22:17:13.327742
"""
from typing import Any, Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b7a0accfc17f"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade(
*,
data_migration: bool = False,
data_migration_options: Any = None,
) -> None:
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()),
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()),
sa.Column("side", sa.INTEGER(), nullable=False),
sa.Column("world_unlock", sa.BOOLEAN(), nullable=False),
sa.Column("remote_download", sa.BOOLEAN()),
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()),
sa.Column("jacket_designer", sa.TEXT()),
sa.Column("jacket_override", sa.BOOLEAN(), nullable=False),
sa.Column("audio_override", sa.BOOLEAN(), nullable=False),
sa.PrimaryKeyConstraint("song_id", "rating_class", name="pk_charts"),
)
op.create_table(
"aliases",
sa.Column("song_id", sa.TEXT(), nullable=False),
sa.Column("alias", sa.TEXT(), nullable=False),
sa.PrimaryKeyConstraint("song_id", "alias", name="pk_aliases"),
)
op.create_table(
"packages",
sa.Column("package_id", sa.TEXT(), nullable=False),
sa.Column("name", sa.TEXT(), nullable=False),
sa.PrimaryKeyConstraint("package_id", name="pk_packages"),
)
op.create_table(
"scores",
sa.Column("id", sa.INTEGER(), autoincrement=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("time", sa.INTEGER(), nullable=False),
sa.Column("max_recall", sa.INTEGER()),
sa.Column("clear_type", sa.INTEGER()),
sa.PrimaryKeyConstraint("id", name="pk_scores"),
sa.ForeignKeyConstraint(
["song_id", "rating_class"],
["charts.song_id", "charts.rating_class"],
name="fk_scores_song_id_charts",
onupdate="CASCADE",
ondelete="NO ACTION",
),
)
properties_tbl = op.create_table(
"properties",
sa.Column("key", sa.TEXT(), nullable=False),
sa.Column("value", sa.TEXT(), nullable=False),
# according to the commit history this was a unique constraint
# but i remember sqlalchemy complains if you don't add a primary key
# anyways above tables have modifications like this too
# and nobody actualty uses v1 schema so who cares
sa.PrimaryKeyConstraint("key", name="pk_properties"),
)
# there should be CREATE VIEWs here but im skipping them
# because nobody actually checks this file
op.execute(sa.insert(properties_tbl).values(key="db_version", value="1"))
def downgrade() -> None:
raise NotImplementedError("This is the first revision bro")

View File

@ -0,0 +1,31 @@
"""version date
Revision ID: f82ac2f445a5
Revises: 0ca6733e40dc
Create Date: 2025-07-19 17:11:27.448574
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f82ac2f445a5"
down_revision: Union[str, None] = "0ca6733e40dc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"version_date",
sa.Column("version", sa.String(), nullable=False),
sa.Column("songlist_at", sa.DateTime(), nullable=False),
sa.Column("published_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("version", name=op.f("pk_version_date")),
)
def downgrade() -> None:
op.drop_table("version_date")

View File

@ -4,6 +4,7 @@ from .config import Property
from .difficulty import Difficulty, DifficultyLocalization from .difficulty import Difficulty, DifficultyLocalization
from .pack import Pack, PackLocalization from .pack import Pack, PackLocalization
from .song import Song, SongLocalization from .song import Song, SongLocalization
from .version_date import VersionDate
from .chart import Chart # isort: skip from .chart import Chart # isort: skip
from .play_result import ( from .play_result import (
@ -29,4 +30,5 @@ __all__ = [
"Property", "Property",
"Song", "Song",
"SongLocalization", "SongLocalization",
"VersionDate",
] ]

View File

@ -4,10 +4,13 @@ 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 ._types import ForceTimezoneDateTime from arcaea_offline.utils import Version
from ._types import ForceTimezoneDateTime, VersionDatabaseType
TYPE_ANNOTATION_MAP = { TYPE_ANNOTATION_MAP = {
datetime: ForceTimezoneDateTime, datetime: ForceTimezoneDateTime,
Version: VersionDatabaseType,
} }

View File

@ -1,9 +1,11 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import DateTime from sqlalchemy import DateTime, String
from sqlalchemy.types import TypeDecorator from sqlalchemy.types import TypeDecorator
from arcaea_offline.utils import Version
class ForceTimezoneDateTime(TypeDecorator): class ForceTimezoneDateTime(TypeDecorator):
""" """
@ -26,3 +28,23 @@ class ForceTimezoneDateTime(TypeDecorator):
if value is not None: if value is not None:
value = value.replace(tzinfo=timezone.utc) value = value.replace(tzinfo=timezone.utc)
return value return value
class VersionDatabaseType(TypeDecorator):
impl = String
cache_ok = True
def process_bind_param(self, value: Optional[Version], dialect):
if value is None:
return None
if not isinstance(value, Version):
raise ValueError("Input is not a Version instance.")
return str(f"{value.first}.{value.second}.{value.third}")
def process_result_value(self, value: Optional[str], dialect):
if value is None:
return None
return Version(*(map(int, value.split("."))))

View File

@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKeyConstraint, Integer, Numeric, String from sqlalchemy import ForeignKeyConstraint, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from arcaea_offline.utils import Version
from ._base import ModelBase, ReprHelper from ._base import ModelBase, ReprHelper
from ._types import ForceTimezoneDateTime from ._types import ForceTimezoneDateTime
@ -28,6 +30,6 @@ class ChartInfo(ModelBase, ReprHelper):
song_id: Mapped[str] = mapped_column(String, primary_key=True) song_id: Mapped[str] = mapped_column(String, primary_key=True)
rating_class: Mapped[int] = mapped_column(Integer, primary_key=True) rating_class: Mapped[int] = mapped_column(Integer, primary_key=True)
constant: Mapped[Decimal] = mapped_column(Numeric, nullable=False) constant: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
notes: Mapped[int] = mapped_column(Integer) notes: Mapped[Optional[int]] = mapped_column(Integer)
added_at: Mapped[datetime] = mapped_column(ForceTimezoneDateTime, primary_key=True) added_at: Mapped[datetime] = mapped_column(ForceTimezoneDateTime, primary_key=True)
version: Mapped[Optional[str]] = mapped_column(String) version: Mapped[Optional[Version]]

View File

@ -13,6 +13,8 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from arcaea_offline.utils import Version
from ._base import ModelBase, ReprHelper from ._base import ModelBase, ReprHelper
from ._types import ForceTimezoneDateTime from ._types import ForceTimezoneDateTime
@ -65,7 +67,7 @@ class Difficulty(ModelBase, ReprHelper):
bpm: Mapped[Optional[str]] = mapped_column(String) bpm: Mapped[Optional[str]] = mapped_column(String)
bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True)) bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True))
added_at: Mapped[Optional[datetime]] = mapped_column(ForceTimezoneDateTime) added_at: Mapped[Optional[datetime]] = mapped_column(ForceTimezoneDateTime)
version: Mapped[Optional[str]] = mapped_column(String) version: Mapped[Optional[Version]]
is_legacy11: Mapped[bool] = mapped_column( is_legacy11: Mapped[bool] = mapped_column(
Boolean, nullable=False, insert_default=False, server_default=text("0") Boolean, nullable=False, insert_default=False, server_default=text("0")
) )

View File

@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String, text from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from arcaea_offline.utils import Version
from ._base import ModelBase, ReprHelper from ._base import ModelBase, ReprHelper
from ._types import ForceTimezoneDateTime from ._types import ForceTimezoneDateTime
@ -42,7 +44,7 @@ class Song(ModelBase, ReprHelper):
added_at: Mapped[datetime] = mapped_column( added_at: Mapped[datetime] = mapped_column(
ForceTimezoneDateTime, nullable=False, index=True ForceTimezoneDateTime, nullable=False, index=True
) )
version: Mapped[Optional[str]] = mapped_column(String) version: Mapped[Optional[Version]]
bpm: Mapped[Optional[str]] = mapped_column(String) bpm: Mapped[Optional[str]] = mapped_column(String)
bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True)) bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True))

View File

@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column
from arcaea_offline.utils import Version
from ._base import ModelBase, ReprHelper
class VersionDate(ModelBase, ReprHelper):
__tablename__ = "version_date"
version: Mapped[Version] = mapped_column(primary_key=True)
songlist_at: Mapped[datetime]
published_at: Mapped[datetime]

View File

@ -0,0 +1,5 @@
from .version import Version
__all__ = [
"Version",
]

View File

@ -0,0 +1,27 @@
from typing import NamedTuple
class Version(NamedTuple):
first: int
second: int
third: int
@classmethod
def from_string(cls, version_str: str):
version_str = version_str.removesuffix("c")
parts = version_str.split(".")
if len(parts) not in {2, 3}:
raise ValueError(f"Invalid version string {version_str}")
try:
if len(parts) == 2: # noqa: PLR2004
parts.append("0")
first, second, third = map(int, parts)
except ValueError as e:
raise ValueError(f"Invalid version string {version_str}") from e
return cls(first, second, third)
def __str__(self):
return f"{self.first}.{self.second}.{self.third}"