From e85728f33cb9deb297ef599ce9a8799e1cf0836c Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Fri, 20 Sep 2024 17:10:48 -0400 Subject: [PATCH 01/17] chuni/mai2: remove upsert from put_playlog --- titles/chuni/schema/score.py | 3 +-- titles/mai2/schema/score.py | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/titles/chuni/schema/score.py b/titles/chuni/schema/score.py index 766b4b9..0d327f8 100644 --- a/titles/chuni/schema/score.py +++ b/titles/chuni/schema/score.py @@ -231,9 +231,8 @@ class ChuniScoreData(BaseData): playlog_data["romVersion"] = romVer.get(version, "1.00.0") sql = insert(playlog).values(**playlog_data) - conflict = sql.on_duplicate_key_update(**playlog_data) - result = await self.execute(conflict) + result = await self.execute(sql) if result is None: return None return result.lastrowid diff --git a/titles/mai2/schema/score.py b/titles/mai2/schema/score.py index d4ea5b9..f62466a 100644 --- a/titles/mai2/schema/score.py +++ b/titles/mai2/schema/score.py @@ -359,9 +359,7 @@ class Mai2ScoreData(BaseData): else: sql = insert(playlog_old).values(**playlog_data) - conflict = sql.on_duplicate_key_update(**playlog_data) - - result = await self.execute(conflict) + result = await self.execute(sql) if result is None: self.logger.error(f"put_playlog: Failed to insert! user_id {user_id} is_dx {is_dx}") return None @@ -371,9 +369,7 @@ class Mai2ScoreData(BaseData): playlog_2p_data["user"] = user_id sql = insert(playlog_2p).values(**playlog_2p_data) - conflict = sql.on_duplicate_key_update(**playlog_2p_data) - - result = await self.execute(conflict) + result = await self.execute(sql) if result is None: self.logger.error(f"put_playlog_2p: Failed to insert! user_id {user_id}") return None From f47175a1440bc71cad51b2cd56fcda0ff4afb89d Mon Sep 17 00:00:00 2001 From: ppc Date: Mon, 23 Sep 2024 17:21:29 +0000 Subject: [PATCH 02/17] [mai2] add buddies plus support (#177) Adds favorite music support (there's an option in the results screen to star a song), handlers for new methods and fixes upsert failures for `userFavoriteList`. The `UserIntimateApi` has been added but didn't seem to add any data during testing, and `CreateTokenApi`/`RemoveTokenApi` have also been added but I think they're only used during guest play. --- Tested on 1.45 with no errors/game crashes (see logs). Card Maker hasn't been tested as I don't have a setup to play with. Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/177 Co-authored-by: ppc Co-committed-by: ppc --- .../28443e2da5b8_mai2_buddies_plus.py | 28 +++++++++ .../versions/54a84103b84e_mai2_intimacy.py | 43 +++++++++++++ ...c91c1206dca_mai2_favorite_song_ordering.py | 24 ++++++++ docs/game_specific_info.md | 1 + readme.md | 1 + titles/cm/read.py | 1 + titles/mai2/base.py | 2 +- titles/mai2/buddiesplus.py | 60 +++++++++++++++++++ titles/mai2/const.py | 4 +- titles/mai2/dx.py | 31 +++++++++- titles/mai2/index.py | 8 ++- titles/mai2/schema/item.py | 12 ++-- titles/mai2/schema/profile.py | 42 ++++++++++++- 13 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 core/data/alembic/versions/28443e2da5b8_mai2_buddies_plus.py create mode 100644 core/data/alembic/versions/54a84103b84e_mai2_intimacy.py create mode 100644 core/data/alembic/versions/bc91c1206dca_mai2_favorite_song_ordering.py create mode 100644 titles/mai2/buddiesplus.py diff --git a/core/data/alembic/versions/28443e2da5b8_mai2_buddies_plus.py b/core/data/alembic/versions/28443e2da5b8_mai2_buddies_plus.py new file mode 100644 index 0000000..42fcdde --- /dev/null +++ b/core/data/alembic/versions/28443e2da5b8_mai2_buddies_plus.py @@ -0,0 +1,28 @@ +"""mai2_buddies_plus + +Revision ID: 28443e2da5b8 +Revises: 5ea73f89d982 +Create Date: 2024-09-15 20:44:02.351819 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '28443e2da5b8' +down_revision = '5ea73f89d982' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('mai2_profile_detail', sa.Column('point', sa.Integer())) + op.add_column('mai2_profile_detail', sa.Column('totalPoint', sa.Integer())) + op.add_column('mai2_profile_detail', sa.Column('friendRegistSkip', sa.SmallInteger())) + + +def downgrade(): + op.drop_column('mai2_profile_detail', 'point') + op.drop_column('mai2_profile_detail', 'totalPoint') + op.drop_column('mai2_profile_detail', 'friendRegistSkip') diff --git a/core/data/alembic/versions/54a84103b84e_mai2_intimacy.py b/core/data/alembic/versions/54a84103b84e_mai2_intimacy.py new file mode 100644 index 0000000..a180bbb --- /dev/null +++ b/core/data/alembic/versions/54a84103b84e_mai2_intimacy.py @@ -0,0 +1,43 @@ +"""mai2_intimacy + +Revision ID: 54a84103b84e +Revises: bc91c1206dca +Create Date: 2024-09-16 17:47:49.164546 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import Column, Integer, UniqueConstraint + +# revision identifiers, used by Alembic. +revision = '54a84103b84e' +down_revision = 'bc91c1206dca' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "mai2_user_intimate", + Column("id", Integer, primary_key=True, nullable=False), + Column("user", Integer, nullable=False), + Column("partnerId", Integer, nullable=False), + Column("intimateLevel", Integer, nullable=False), + Column("intimateCountRewarded", Integer, nullable=False), + UniqueConstraint("user", "partnerId", name="mai2_user_intimate_uk"), + mysql_charset="utf8mb4", + ) + + op.create_foreign_key( + None, + "mai2_user_intimate", + "aime_user", + ["user"], + ["id"], + ondelete="cascade", + onupdate="cascade", + ) + + +def downgrade(): + op.drop_table("mai2_user_intimate") diff --git a/core/data/alembic/versions/bc91c1206dca_mai2_favorite_song_ordering.py b/core/data/alembic/versions/bc91c1206dca_mai2_favorite_song_ordering.py new file mode 100644 index 0000000..abf6357 --- /dev/null +++ b/core/data/alembic/versions/bc91c1206dca_mai2_favorite_song_ordering.py @@ -0,0 +1,24 @@ +"""mai2_favorite_song_ordering + +Revision ID: bc91c1206dca +Revises: 28443e2da5b8 +Create Date: 2024-09-16 14:24:56.714066 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bc91c1206dca' +down_revision = '28443e2da5b8' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('mai2_item_favorite_music', sa.Column('orderId', sa.Integer(), nullable=True)) + + +def downgrade(): + op.drop_column('mai2_item_favorite_music', 'orderId') diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 37561fc..a0aed71 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -218,6 +218,7 @@ Presents are items given to the user when they login, with a little animation (f | SDEZ | 19 | maimai DX FESTiVAL | | SDEZ | 20 | maimai DX FESTiVAL PLUS | | SDEZ | 21 | maimai DX BUDDiES | +| SDEZ | 22 | maimai DX BUDDiES PLUS | ### Importer diff --git a/readme.md b/readme.md index c792928..0564c70 100644 --- a/readme.md +++ b/readme.md @@ -50,6 +50,7 @@ Games listed below have been tested and confirmed working. Only game versions ol + FESTiVAL + FESTiVAL PLUS + BUDDiES + + BUDDiES PLUS + O.N.G.E.K.I. + SUMMER diff --git a/titles/cm/read.py b/titles/cm/read.py index 2b5ec8a..b4b3b5e 100644 --- a/titles/cm/read.py +++ b/titles/cm/read.py @@ -207,6 +207,7 @@ class CardMakerReader(BaseReader): "1.30": Mai2Constants.VER_MAIMAI_DX_FESTIVAL, "1.35": Mai2Constants.VER_MAIMAI_DX_FESTIVAL_PLUS, "1.40": Mai2Constants.VER_MAIMAI_DX_BUDDIES, + "1.45": Mai2Constants.VER_MAIMAI_DX_BUDDIES_PLUS } for root, dirs, files in os.walk(base_dir): diff --git a/titles/mai2/base.py b/titles/mai2/base.py index bbd074f..b041028 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -922,7 +922,7 @@ class Mai2Base: fav_music = await self.data.item.get_fav_music(user_id) if fav_music: for fav in fav_music: - id_list.append({"orderId": 0, "id": fav["musicId"]}) + id_list.append({"orderId": fav["orderId"] or 0, "id": fav["musicId"]}) if len(id_list) >= 100: # Lazy but whatever break diff --git a/titles/mai2/buddiesplus.py b/titles/mai2/buddiesplus.py new file mode 100644 index 0000000..e87fae6 --- /dev/null +++ b/titles/mai2/buddiesplus.py @@ -0,0 +1,60 @@ +from typing import Dict + +from core.config import CoreConfig +from titles.mai2.buddies import Mai2Buddies +from titles.mai2.const import Mai2Constants +from titles.mai2.config import Mai2Config + + +class Mai2BuddiesPlus(Mai2Buddies): + def __init__(self, cfg: CoreConfig, game_cfg: Mai2Config) -> None: + super().__init__(cfg, game_cfg) + self.version = Mai2Constants.VER_MAIMAI_DX_BUDDIES_PLUS + + async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict: + user_data = await super().handle_cm_get_user_preview_api_request(data) + + # hardcode lastDataVersion for CardMaker + user_data["lastDataVersion"] = "1.45.00" + return user_data + + async def handle_get_game_weekly_data_api_request(self, data: Dict) -> Dict: + return { + "gameWeeklyData": { + "missionCategory": 0, + "updateDate": "2024-03-21 09:00:00", + "beforeDate": "2099-12-31 00:00:00" + } + } + + async def handle_create_token_api_request(self, data: Dict) -> Dict: + return { + "Bearer": "ARTEMiSTOKEN" # duplicate of handle_user_login_api_request from Mai2Festival + } + + async def handle_remove_token_api_request(self, data: Dict) -> Dict: + return {} + + async def handle_get_user_friend_bonus_api_request(self, data: Dict) -> Dict: + return { + "userId": data["userId"], + "returnCode": 1, + "getMiles": 0 + } + + async def handle_get_user_shop_stock_api_request(self, data: Dict) -> Dict: + return { + "userId": data["userId"], + "userShopStockList": [] + } + + async def handle_get_user_mission_data_api_request(self, data: Dict) -> Dict: + return { + "userId": data["userId"], + "userMissionDataList": [], + "userWeeklyData": { + "lastLoginWeek": "2024-03-21 09:00:00", + "beforeLoginWeek": "2099-12-31 00:00:00", + "friendBonusFlag": False + } + } diff --git a/titles/mai2/const.py b/titles/mai2/const.py index 4dc10ce..0d13a0d 100644 --- a/titles/mai2/const.py +++ b/titles/mai2/const.py @@ -55,6 +55,7 @@ class Mai2Constants: VER_MAIMAI_DX_FESTIVAL = 19 VER_MAIMAI_DX_FESTIVAL_PLUS = 20 VER_MAIMAI_DX_BUDDIES = 21 + VER_MAIMAI_DX_BUDDIES_PLUS = 22 VERSION_STRING = ( "maimai", @@ -78,7 +79,8 @@ class Mai2Constants: "maimai DX UNiVERSE PLUS", "maimai DX FESTiVAL", "maimai DX FESTiVAL PLUS", - "maimai DX BUDDiES" + "maimai DX BUDDiES", + "maimai DX BUDDiES PLUS" ) @classmethod diff --git a/titles/mai2/dx.py b/titles/mai2/dx.py index 7a067d7..66bf914 100644 --- a/titles/mai2/dx.py +++ b/titles/mai2/dx.py @@ -242,7 +242,13 @@ class Mai2DX(Mai2Base): if "userFavoriteList" in upsert and len(upsert["userFavoriteList"]) > 0: for fav in upsert["userFavoriteList"]: - await self.data.item.put_favorite(user_id, fav["kind"], fav["itemIdList"]) + kind_id = fav.get("kind", fav.get("itemKind")) # itemKind key used in BUDDiES+ + if kind_id is not None: + await self.data.item.put_favorite(user_id, kind_id, fav["itemIdList"]) + + if "userFavoritemusicList" in upsert and len(upsert["userFavoritemusicList"]) > 0: + for fav in upsert["userFavoritemusicList"]: + await self.data.item.add_fav_music(user_id, fav["id"], fav["orderId"]) if ( "userFriendSeasonRankingList" in upsert @@ -259,6 +265,11 @@ class Mai2DX(Mai2Base): if "user2pPlaylog" in upsert: await self.data.score.put_playlog_2p(user_id, upsert["user2pPlaylog"]) + # added in BUDDiES+ + if "userIntimateList" in upsert and len(upsert["userIntimateList"]) > 0: + for intimate in upsert["userIntimateList"]: + await self.data.profile.put_intimacy(user_id, intimate["partnerId"], intimate["intimateLevel"], intimate["intimateCountRewarded"]) + return {"returnCode": 1, "apiName": "UpsertUserAllApi"} async def handle_get_user_data_api_request(self, data: Dict) -> Dict: @@ -708,6 +719,24 @@ class Mai2DX(Mai2Base): ret['loginId'] = ret.get('loginCount', 0) return ret + # Intimate api added in BUDDiES+ + async def handle_get_user_intimate_api_request(self, data: Dict) -> Dict: + intimate = await self.data.profile.get_intimacy(data["userId"]) + if intimate is None: + return {} + + partner_list = [{ + "partnerId": i["partnerId"], + "intimateLevel": i["intimateLevel"], + "intimateCountRewarded": i["intimateCountRewarded"] + } for i in intimate] + + return { + "userId": data["userId"], + "length": len(partner_list), + "userIntimateList": partner_list + } + # CardMaker support added in Universe async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict: p = await self.data.profile.get_profile_detail(data["userId"], self.version) diff --git a/titles/mai2/index.py b/titles/mai2/index.py index ad02648..e8b88ec 100644 --- a/titles/mai2/index.py +++ b/titles/mai2/index.py @@ -30,6 +30,7 @@ from .universeplus import Mai2UniversePlus from .festival import Mai2Festival from .festivalplus import Mai2FestivalPlus from .buddies import Mai2Buddies +from .buddiesplus import Mai2BuddiesPlus class Mai2Servlet(BaseServlet): @@ -64,7 +65,8 @@ class Mai2Servlet(BaseServlet): Mai2UniversePlus, Mai2Festival, Mai2FestivalPlus, - Mai2Buddies + Mai2Buddies, + Mai2BuddiesPlus ] self.logger = logging.getLogger("mai2") @@ -302,8 +304,10 @@ class Mai2Servlet(BaseServlet): internal_ver = Mai2Constants.VER_MAIMAI_DX_FESTIVAL elif version >= 135 and version < 140: # FESTiVAL PLUS internal_ver = Mai2Constants.VER_MAIMAI_DX_FESTIVAL_PLUS - elif version >= 140: # BUDDiES + elif version >= 140 and version < 145: # BUDDiES internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES + elif version >= 145: # BUDDiES PLUS + internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES_PLUS elif game_code == "SDGA": # Int if version < 105: # 1.0 internal_ver = Mai2Constants.VER_MAIMAI_DX diff --git a/titles/mai2/schema/item.py b/titles/mai2/schema/item.py index d53ebbc..87ddca4 100644 --- a/titles/mai2/schema/item.py +++ b/titles/mai2/schema/item.py @@ -144,6 +144,7 @@ fav_music = Table( nullable=False, ), Column("musicId", Integer, nullable=False), + Column("orderId", Integer, nullable=True), UniqueConstraint("user", "musicId", name="mai2_item_favorite_music_uk"), mysql_charset="utf8mb4", ) @@ -453,10 +454,10 @@ class Mai2ItemData(BaseData): self, user_id: int, kind: int, item_id_list: List[int] ) -> Optional[int]: sql = insert(favorite).values( - user=user_id, kind=kind, item_id_list=item_id_list + user=user_id, itemKind=kind, itemIdList=item_id_list ) - conflict = sql.on_duplicate_key_update(item_id_list=item_id_list) + conflict = sql.on_duplicate_key_update(itemIdList=item_id_list) result = await self.execute(conflict) if result is None: @@ -484,13 +485,14 @@ class Mai2ItemData(BaseData): if result: return result.fetchall() - async def add_fav_music(self, user_id: int, music_id: int) -> Optional[int]: + async def add_fav_music(self, user_id: int, music_id: int, order_id: Optional[int] = None) -> Optional[int]: sql = insert(fav_music).values( user = user_id, - musicId = music_id + musicId = music_id, + orderId = order_id ) - conflict = sql.on_duplicate_key_update(musicId = music_id) + conflict = sql.on_duplicate_key_update(orderId = order_id) result = await self.execute(conflict) if result: diff --git a/titles/mai2/schema/profile.py b/titles/mai2/schema/profile.py index c191a1a..3ff85d2 100644 --- a/titles/mai2/schema/profile.py +++ b/titles/mai2/schema/profile.py @@ -3,7 +3,7 @@ from titles.mai2.const import Mai2Constants from typing import Optional, Dict, List from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_ -from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON, BigInteger +from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON, BigInteger, SmallInteger from sqlalchemy.schema import ForeignKey from sqlalchemy.sql import func, select from sqlalchemy.engine import Row @@ -43,6 +43,8 @@ detail = Table( Column("currentPlayCount", Integer), # new with buddies Column("renameCredit", Integer), # new with buddies Column("mapStock", Integer), # new with fes+ + Column("point", Integer), # new with buddies+ + Column("totalPoint", Integer), # new with buddies+ Column("eventWatchedDate", String(25)), Column("lastGameId", String(25)), Column("lastRomVersion", String(25)), @@ -97,6 +99,7 @@ detail = Table( Column("playerOldRating", BigInteger), Column("playerNewRating", BigInteger), Column("dateTime", BigInteger), + Column("friendRegistSkip", SmallInteger), # new with buddies+ Column("banState", Integer), # new with uni+ UniqueConstraint("user", "version", name="mai2_profile_detail_uk"), mysql_charset="utf8mb4", @@ -510,6 +513,22 @@ rival = Table( mysql_charset="utf8mb4", ) +intimacy = Table( +"mai2_user_intimate", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("partnerId", Integer, nullable=False), + Column("intimateLevel", Integer, nullable=False), + Column("intimateCountRewarded", Integer, nullable=False), + UniqueConstraint("user", "partnerId", name="mai2_user_intimate_uk"), + mysql_charset="utf8mb4", +) + class Mai2ProfileData(BaseData): async def get_all_profile_versions(self, user_id: int) -> Optional[List[Row]]: result = await self.execute(detail.select(detail.c.user == user_id)) @@ -905,6 +924,27 @@ class Mai2ProfileData(BaseData): if not result: self.logger.error(f"Failed to remove rival {rival_id} for user {user_id}!") + async def get_intimacy(self, user_id: int) -> Optional[List[Row]]: + result = await self.execute(intimacy.select(intimacy.c.user == user_id)) + if result: + return result.fetchall() + + async def put_intimacy(self, user_id: int, partner_id: int, level: int, count_rewarded: int) -> Optional[int]: + sql = insert(intimacy).values( + user = user_id, + partnerId = partner_id, + intimateLevel = level, + intimateCountRewarded = count_rewarded + ) + + conflict = sql.on_duplicate_key_update(intimateLevel = level, intimateCountRewarded = count_rewarded) + + result = await self.execute(conflict) + if result: + return result.lastrowid + + self.logger.error(f"Failed to update intimacy for user {user_id} and partner {partner_id}!") + async def update_name(self, user_id: int, new_name: str) -> bool: sql = detail.update(detail.c.user == user_id).values( userName=new_name From aa8e33a13ee33082a139cd65a6c40c4513033a95 Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Mon, 23 Sep 2024 14:20:25 -0400 Subject: [PATCH 03/17] docs: add pokken to game specific info --- docs/game_specific_info.md | 80 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index a0aed71..6c938d7 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -31,6 +31,7 @@ python dbutils.py migrate - [WACCA](#wacca) - [Sword Art Online Arcade](#sao) - [Initial D THE ARCADE](#initial-d-the-arcade) + - [Pokken Tournament](#pokken) # Supported Games @@ -797,3 +798,82 @@ python dbutils.py upgrade A huge thanks to all people who helped shaping this project to what it is now and don't want to be mentioned here. +## Pokken + +### SDAK + +| Version ID | Version Name | +| ---------- | ------------ | +| 0 | Pokken | + +### Config + +Config file is `pokken.yaml` + +#### server + +| Option | Info | Default | +| ------ | ---- | ------- | +| `hostname` | Hostname override for allnet to tell the game where to connect. Useful for local setups that need to use a different hostname for pokken's proxy. Otherwise, it should match `server`->`hostname` in `core.yaml`. | `localhost` | +| `enabled` | `True` if the pokken service should be enabled. `False` otherwise. | `True` | +| `loglevel` | String indicating how verbose pokken logs should be. Acceptable values are `debug`, `info`, `warn`, and `error`. | `info` | +| `auto_register` | For games that don't use aimedb, this controls weather connecting cards that aren't registered should automatically be registered when making a profile. Set to `False` to require cards be already registered before being usable with Pokken. | `True` | +| `enable_matching` | If `True`, allow non-local matching. This doesn't currently work because BIWA, the matching protocol the game uses, is not understood, so this should be set to `False`. | `False` | +| `stun_server_host` | Hostname of the STUN server the game will use for matching. | `stunserver.stunprotocol.org` (might not work anymore? recomend changing) | +| `stun_server_port` | Port for the external STUN server. Will probably be moved to the `ports` section in the future. | `3478` | + +#### ports +| Option | Info | Default | +| ------ | ---- | ------- | +| `game` | Override for the title server port sent by allnet. Useful for local setups utalizing NGINX. | `9000` | +| `admission` | Port for the admission server used in global matching. May be obsolited later. | `9001` | + +### Connecting to Artemis + +Pokken is a bit tricky to get working due to it having a hard requirement of the connection being HTTPS. This is simplified somewhat by Pokken simply not validating the certificate in any way, shape or form (it can be self-signed, expired, for a different domain, etc.) but it does have to be there. The work-around is to spin up a local NGINX (or other proxy) instance and point traffic back to artemis. See below for a sample nginx config: +`nginx.conf` +```conf +# This example assumes your artemis instance is configured to listed on port 8080, and your certs exists at /path/to/cert and are called title.crt and title.key. +server { + listen 443 ssl; + server_name your.hostname.here; + + ssl_certificate /path/to/cert/title.crt; + ssl_certificate_key /path/to/cert/title.key; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; + ssl_session_tickets off; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; + ssl_ciphers "ALL:@SECLEVEL=0"; + ssl_prefer_server_ciphers off; + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass_request_headers on; + proxy_pass http://127.0.0.1:8080/; + } +} +``` +`pokken.yaml` +```yaml +server: + hostname: "your.hostname.here" + enable: True + loglevel: "info" + auto_register: True + enable_matching: False + stun_server_host: "stunserver.stunprotocol.org" + stun_server_port: 3478 + +ports: + game: 443 + admission: 9001 +``` + +### Info + +The arcade release is missing a few fighters and supports compared to the switch version. It may be possible to mod these in in the future, but not much headway has been made on this as far as I know. Mercifully, the game uses the pokedex number (illustration_book_no) wherever possible when referingto both fighters and supports. Customization is entirely done on the webui. Artemis currently only supports changing your name, gender, and supporrt teams, but more is planned for the future. + +### Credits +Special thanks to Pocky for pointing me in the right direction in terms of getting this game to function at all, and Lightning and other pokken cab owners for doing testing and reporting bugs/issues. From 045465ed4ed9e18cbf3bd4247d2fe3a6552ec675 Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Mon, 23 Sep 2024 14:46:41 -0400 Subject: [PATCH 04/17] idz: disabled by default to silence warnings for people who don't feel like configuring games they don't intend to use --- example_config/idz.yaml | 2 +- titles/idz/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example_config/idz.yaml b/example_config/idz.yaml index 1bfff9b..3ec39b1 100644 --- a/example_config/idz.yaml +++ b/example_config/idz.yaml @@ -1,5 +1,5 @@ server: - enable: True + enable: False loglevel: "info" hostname: "" news: "" diff --git a/titles/idz/config.py b/titles/idz/config.py index f7af4fd..3c8e870 100644 --- a/titles/idz/config.py +++ b/titles/idz/config.py @@ -10,7 +10,7 @@ class IDZServerConfig: @property def enable(self) -> bool: return CoreConfig.get_config_field( - self.__config, "idz", "server", "enable", default=True + self.__config, "idz", "server", "enable", default=False ) @property From 1d8e31d4abca3149c713df8416744dfd5f51995a Mon Sep 17 00:00:00 2001 From: Hay1tsme Date: Mon, 23 Sep 2024 14:46:48 -0400 Subject: [PATCH 05/17] docs: add missing games --- docs/game_specific_info.md | 44 ++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 6c938d7..4231297 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -26,10 +26,12 @@ python dbutils.py migrate - [CHUNITHM](#chunithm) - [crossbeats REV.](#crossbeats-rev) - [maimai DX](#maimai-dx) + - [Project Diva](#hatsune-miku-project-diva) - [O.N.G.E.K.I.](#o-n-g-e-k-i) - [Card Maker](#card-maker) - [WACCA](#wacca) - [Sword Art Online Arcade](#sao) + - [Initial D Zero](#initial-d-zero) - [Initial D THE ARCADE](#initial-d-the-arcade) - [Pokken Tournament](#pokken) @@ -294,6 +296,23 @@ Always make sure your database (tables) are up-to-date: python dbutils.py upgrade ``` +### Using NGINX + +Diva's netcode does not send a `Host` header with it's network requests. This renders it incompatable with NGINX as configured in the example config, because nginx relies on the header to determine how to proxy the request. If you'd still like to use NGINX with diva, please see the sample config below. + +```conf +server { + listen 80 default_server; + server_name _; + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass_request_headers on; + proxy_pass http://127.0.0.1:8080/; + } +} +``` + ## O.N.G.E.K.I. ### SDDT @@ -652,21 +671,32 @@ python dbutils.py upgrade ``` ### Notes -- Defrag Match will crash at loading -- Co-Op Online is not supported -- Shop is displayed but cannot purchase heroes or items +- Defrag Match and online coop requires a cloud instance of Photon and a working application ID - Player title is currently static and cannot be changed in-game -- QR Card Scanning currently only load a static hero -- Ex-quests progression not supported yet +- QR Card Scanning of existing cards requires them to be registered on the webui - Daily Missions not implemented -- EX TOWER 1,2 & 3 are not yet supported -- Daily Yui coin not yet fixed +- Terminal functionality is almost entirely untested ### Credits for SAO support: - Midorica - Network Support - Dniel97 - Helping with network base - tungnotpunk - Source +- Hay1tsme - fixing many issues with the original implemetation + +## Initial D Zero +### SDDF + +| Version ID | Version Name | +| ---------- | -------------------- | +| 0 | Initial D Zero v1.10 | +| 1 | Initial D Zero v1.30 | +| 2 | Initial D Zero v2.10 | +| 3 | Initial D Zero v2.30 | + +### Info + +TODO, probably just leave disabled unless you're doing development things for it. ## Initial D THE ARCADE From b04840f3dd3fbb516d176a3d031ba8dc3679afec Mon Sep 17 00:00:00 2001 From: daydensteve Date: Wed, 25 Sep 2024 14:53:43 +0000 Subject: [PATCH 06/17] [chuni] Frontend favorites support (#176) I had been itching for the favorites feature since I'm bad with japanese so figured I'd go ahead and add it. I've included a few pics to help visualize the changes. ### Summary of user-facing changes: - New Favorites frontend page that itemizes favorites by genre for the current version (as selected on the Profile page). Favorites can be removed from this page via the Remove button - Updated the Records page so that it only shows the playlog for the currently selected version and includes a "star" to the left of each title that can be clicked to add/remove favorites. When the star is yellow, its a favorite; when its a grey outline, its not. I figure its pretty straight forward - The Records and new Favorites pages show the jacket image of each song now (The Importer was updated to convert the DDS files to PNGs on import) ### Behind-the-scenes changes: - Fixed a bug in the chuni get_song method - it was inappropriately comparing the row id instead of the musicid (note this method was not used prior to adding favorites support) - Overhauled the score scheme file to stop with all the hacky romVersion determination that was going on in various methods. To do this, I created a new ChuniRomVersion class that is populated with all base rom versions, then used to derive the internal integer version number from the string stored in the DB. As written, this functionality can infer recorded rom versions when the playlog was entered using an update to the base version (e.g. 2.16 vs 2.15 for sunplus or 2.22 vs 2.20 for luminous). - Made the chuni config version class safer as it would previously throw an exception if you gave it a version not present in the config file. This was done in support of the score overhaul to build up the initial ChuniRomVersion dict - Added necessary methods to query/update the favorites table. ### Testing - Frontend testing was performed with playlog data for both sunplus (2.16) and luminous (2.22) present. All add/remove permutations and images behavior was as expected - Game testing was performed only with Luminous (2.22) and worked fine Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/176 Co-authored-by: daydensteve Co-committed-by: daydensteve --- titles/chuni/config.py | 9 +- titles/chuni/database.py | 8 +- titles/chuni/frontend.py | 122 +++++++++++- titles/chuni/img/jacket/unknown.png | Bin 0 -> 27489 bytes titles/chuni/read.py | 11 ++ titles/chuni/schema/__init__.py | 4 +- titles/chuni/schema/item.py | 44 +++++ titles/chuni/schema/score.py | 186 ++++++++++++++----- titles/chuni/schema/static.py | 2 +- titles/chuni/templates/chuni_favorites.jinja | 55 ++++++ titles/chuni/templates/chuni_header.jinja | 3 + titles/chuni/templates/chuni_playlog.jinja | 23 ++- titles/chuni/templates/css/chuni_style.css | 17 ++ 13 files changed, 418 insertions(+), 66 deletions(-) create mode 100644 titles/chuni/img/jacket/unknown.png create mode 100644 titles/chuni/templates/chuni_favorites.jinja diff --git a/titles/chuni/config.py b/titles/chuni/config.py index dcdfce4..51f819c 100644 --- a/titles/chuni/config.py +++ b/titles/chuni/config.py @@ -75,9 +75,14 @@ class ChuniVersionConfig: in the form of: 11: {"rom": 2.00.00, "data": 2.00.00} """ - return CoreConfig.get_config_field( + versions = CoreConfig.get_config_field( self.__config, "chuni", "version", default={} - )[version] + ) + + if version not in versions.keys(): + return None + + return versions[version] class ChuniCryptoConfig: diff --git a/titles/chuni/database.py b/titles/chuni/database.py index eeb588c..1d5b800 100644 --- a/titles/chuni/database.py +++ b/titles/chuni/database.py @@ -1,13 +1,17 @@ from core.data import Data from core.config import CoreConfig from titles.chuni.schema import * - +from .config import ChuniConfig class ChuniData(Data): - def __init__(self, cfg: CoreConfig) -> None: + def __init__(self, cfg: CoreConfig, chuni_cfg: ChuniConfig = None) -> None: super().__init__(cfg) self.item = ChuniItemData(cfg, self.session) self.profile = ChuniProfileData(cfg, self.session) self.score = ChuniScoreData(cfg, self.session) self.static = ChuniStaticData(cfg, self.session) + + # init rom versioning for use with score playlog data + if chuni_cfg: + ChuniRomVersion.init_versions(chuni_cfg) diff --git a/titles/chuni/frontend.py b/titles/chuni/frontend.py index 74f7794..69f1ae9 100644 --- a/titles/chuni/frontend.py +++ b/titles/chuni/frontend.py @@ -2,6 +2,7 @@ from typing import List from starlette.routing import Route, Mount from starlette.requests import Request from starlette.responses import Response, RedirectResponse +from starlette.staticfiles import StaticFiles from os import path import yaml import jinja2 @@ -81,12 +82,12 @@ class ChuniFrontend(FE_Base): self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str ) -> None: super().__init__(cfg, environment) - self.data = ChuniData(cfg) self.game_cfg = ChuniConfig() if path.exists(f"{cfg_dir}/{ChuniConstants.CONFIG_NAME}"): self.game_cfg.update( yaml.safe_load(open(f"{cfg_dir}/{ChuniConstants.CONFIG_NAME}")) ) + self.data = ChuniData(cfg, self.game_cfg) self.nav_name = "Chunithm" def get_routes(self) -> List[Route]: @@ -97,8 +98,12 @@ class ChuniFrontend(FE_Base): Route("/", self.render_GET_playlog, methods=['GET']), Route("/{index}", self.render_GET_playlog, methods=['GET']), ]), + Route("/favorites", self.render_GET_favorites, methods=['GET']), Route("/update.name", self.update_name, methods=['POST']), + Route("/update.favorite_music_playlog", self.update_favorite_music_playlog, methods=['POST']), + Route("/update.favorite_music_favorites", self.update_favorite_music_favorites, methods=['POST']), Route("/version.change", self.version_change, methods=['POST']), + Mount('/img', app=StaticFiles(directory='titles/chuni/img'), name="img") ] async def render_GET(self, request: Request) -> bytes: @@ -205,7 +210,8 @@ class ChuniFrontend(FE_Base): else: index = int(path_index) - 1 # 0 and 1 are 1st page user_id = usr_sesh.user_id - playlog_count = await self.data.score.get_user_playlogs_count(user_id) + version = usr_sesh.chunithm_version + playlog_count = await self.data.score.get_user_playlogs_count(user_id, version) if playlog_count < index * 20 : return Response(template.render( title=f"{self.core_config.server.name} | {self.nav_name}", @@ -213,31 +219,107 @@ class ChuniFrontend(FE_Base): sesh=vars(usr_sesh), playlog_count=0 ), media_type="text/html; charset=utf-8") - playlog = await self.data.score.get_playlogs_limited(user_id, index, 20) + playlog = await self.data.score.get_playlogs_limited(user_id, version, index, 20) playlog_with_title = [] - for record in playlog: - music_chart = await self.data.static.get_music_chart(usr_sesh.chunithm_version, record.musicId, record.level) + for idx,record in enumerate(playlog): + music_chart = await self.data.static.get_music_chart(version, record.musicId, record.level) if music_chart: difficultyNum=music_chart.level artist=music_chart.artist title=music_chart.title + (jacket, ext) = path.splitext(music_chart.jacketPath) + jacket += ".png" else: difficultyNum=0 artist="unknown" title="musicid: " + str(record.musicId) + jacket = "unknown.png" + + # Check if this song is a favorite so we can populate the add/remove button + is_favorite = await self.data.item.is_favorite(user_id, version, record.musicId) + playlog_with_title.append({ + # Values for the actual readable results "raw": record, "title": title, "difficultyNum": difficultyNum, "artist": artist, + "jacket": jacket, + # Values used solely for favorite updates + "idx": idx, + "musicId": record.musicId, + "isFav": is_favorite }) return Response(template.render( title=f"{self.core_config.server.name} | {self.nav_name}", game_list=self.environment.globals["game_list"], sesh=vars(usr_sesh), - user_id=usr_sesh.user_id, + user_id=user_id, playlog=playlog_with_title, - playlog_count=playlog_count + playlog_count=playlog_count, + cur_version_name=ChuniConstants.game_ver_to_string(version) + ), media_type="text/html; charset=utf-8") + else: + return RedirectResponse("/gate/", 303) + + async def render_GET_favorites(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/chuni/templates/chuni_favorites.jinja" + ) + usr_sesh = self.validate_session(request) + if not usr_sesh: + usr_sesh = UserSession() + + if usr_sesh.user_id > 0: + if usr_sesh.chunithm_version < 0: + return RedirectResponse("/game/chuni/", 303) + + user_id = usr_sesh.user_id + version = usr_sesh.chunithm_version + favorites = await self.data.item.get_all_favorites(user_id, version, 1) + favorites_count = len(favorites) + favorites_with_title = [] + favorites_by_genre = dict() + for idx,favorite in enumerate(favorites): + song = await self.data.static.get_song(favorite.favId) + if song: + # we likely got multiple results - one for each chart. Just use the first + artist=song.artist + title=song.title + genre=song.genre + (jacket, ext) = path.splitext(song.jacketPath) + jacket += ".png" + else: + artist="unknown" + title="musicid: " + str(favorite.favId) + genre="unknown" + jacket = "unknown.png" + + # add a new collection for the genre if this is our first time seeing it + if genre not in favorites_by_genre: + favorites_by_genre[genre] = [] + + # add the song to the appropriate genre collection + favorites_by_genre[genre].append({ + "idx": idx, + "title": title, + "artist": artist, + "jacket": jacket, + "favId": favorite.favId + }) + + # Sort favorites by title before rendering the page + for g in favorites_by_genre: + favorites_by_genre[g].sort(key=lambda x: x["title"].lower()) + + return Response(template.render( + title=f"{self.core_config.server.name} | {self.nav_name}", + game_list=self.environment.globals["game_list"], + sesh=vars(usr_sesh), + user_id=user_id, + favorites_by_genre=favorites_by_genre, + favorites_count=favorites_count, + cur_version_name=ChuniConstants.game_ver_to_string(version) ), media_type="text/html; charset=utf-8") else: return RedirectResponse("/gate/", 303) @@ -279,6 +361,32 @@ class ChuniFrontend(FE_Base): return RedirectResponse("/game/chuni/?s=1", 303) + async def update_favorite_music(self, request: Request, retPage: str): + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse(retPage, 303) + + user_id = usr_sesh.user_id + version = usr_sesh.chunithm_version + form_data = await request.form() + music_id: str = form_data.get("musicId") + isAdd: int = int(form_data.get("isAdd")) + + if isAdd: + if await self.data.item.put_favorite_music(user_id, version, music_id) == None: + return RedirectResponse("/gate/?e=999", 303) + else: + if await self.data.item.delete_favorite_music(user_id, version, music_id) == None: + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse(retPage, 303) + + async def update_favorite_music_playlog(self, request: Request): + return await self.update_favorite_music(request, "/game/chuni/playlog") + + async def update_favorite_music_favorites(self, request: Request): + return await self.update_favorite_music(request, "/game/chuni/favorites") + async def version_change(self, request: Request): usr_sesh = self.validate_session(request) if not usr_sesh: diff --git a/titles/chuni/img/jacket/unknown.png b/titles/chuni/img/jacket/unknown.png new file mode 100644 index 0000000000000000000000000000000000000000..92a72d65f31c3df93a8fed7b5756caf8c40154dd GIT binary patch literal 27489 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 Ux!TjW&N^}Tw&|_P8%`1b2M}vu@&Et; literal 0 HcmV?d00001 diff --git a/titles/chuni/read.py b/titles/chuni/read.py index 15557d4..12b0d9e 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -2,6 +2,7 @@ from typing import Optional from os import walk, path import xml.etree.ElementTree as ET from read import BaseReader +from PIL import Image from core.config import CoreConfig from titles.chuni.database import ChuniData @@ -164,6 +165,16 @@ class ChuniReader(BaseReader): for jaketFile in xml_root.findall("jaketFile"): # nice typo, SEGA jacket_path = jaketFile.find("path").text + # Convert the image to png and save it for use in the frontend + jacket_filename_src = f"{root}/{dir}/{jacket_path}" + (pre, ext) = path.splitext(jacket_path) + jacket_filename_dst = f"titles/chuni/img/jacket/{pre}.png" + if path.exists(jacket_filename_src) and not path.exists(jacket_filename_dst): + try: + im = Image.open(jacket_filename_src) + im.save(jacket_filename_dst) + except Exception: + self.logger.warning(f"Failed to convert {jacket_path} to png") for fumens in xml_root.findall("fumens"): for MusicFumenData in fumens.findall("MusicFumenData"): diff --git a/titles/chuni/schema/__init__.py b/titles/chuni/schema/__init__.py index 51d950b..cf1f5f2 100644 --- a/titles/chuni/schema/__init__.py +++ b/titles/chuni/schema/__init__.py @@ -1,6 +1,6 @@ from titles.chuni.schema.profile import ChuniProfileData -from titles.chuni.schema.score import ChuniScoreData +from titles.chuni.schema.score import ChuniScoreData, ChuniRomVersion from titles.chuni.schema.item import ChuniItemData from titles.chuni.schema.static import ChuniStaticData -__all__ = ["ChuniProfileData", "ChuniScoreData", "ChuniItemData", "ChuniStaticData"] +__all__ = ["ChuniProfileData", "ChuniScoreData", "ChuniRomVersion", "ChuniItemData", "ChuniStaticData"] diff --git a/titles/chuni/schema/item.py b/titles/chuni/schema/item.py index 9ce2c53..92910da 100644 --- a/titles/chuni/schema/item.py +++ b/titles/chuni/schema/item.py @@ -359,6 +359,25 @@ class ChuniItemData(BaseData): return None return result.lastrowid + async def is_favorite( + self, user_id: int, version: int, fav_id: int, fav_kind: int = 1 + ) -> bool: + + sql = favorite.select( + and_( + favorite.c.version == version, + favorite.c.user == user_id, + favorite.c.favId == fav_id, + favorite.c.favKind == fav_kind, + ) + ) + + result = await self.execute(sql) + if result is None: + return False + + return True if len(result.all()) else False + async def get_all_favorites( self, user_id: int, version: int, fav_kind: int = 1 ) -> Optional[List[Row]]: @@ -421,6 +440,31 @@ class ChuniItemData(BaseData): return None return result.fetchone() + async def put_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]: + sql = insert(favorite).values(user=user_id, version=version, favId=music_id, favKind=1) + + conflict = sql.on_duplicate_key_update(user=user_id, version=version, favId=music_id, favKind=1) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def delete_favorite_music(self, user_id: int, version: int, music_id: int) -> Optional[int]: + sql = delete(favorite).where( + and_( + favorite.c.user==user_id, + favorite.c.version==version, + favorite.c.favId==music_id, + favorite.c.favKind==1 + ) + ) + + result = await self.execute(sql) + if result is None: + return None + return result.lastrowid + async def put_character(self, user_id: int, character_data: Dict) -> Optional[int]: character_data["user"] = user_id diff --git a/titles/chuni/schema/score.py b/titles/chuni/schema/score.py index 0d327f8..308afa8 100644 --- a/titles/chuni/schema/score.py +++ b/titles/chuni/schema/score.py @@ -8,6 +8,7 @@ from sqlalchemy.sql import func, select from sqlalchemy.dialects.mysql import insert from sqlalchemy.sql.expression import exists from core.data.schema import BaseData, metadata +from ..config import ChuniConfig course = Table( "chuni_score_course", @@ -140,6 +141,92 @@ playlog = Table( mysql_charset="utf8mb4" ) +class ChuniRomVersion(): + """ + Class used to easily compare rom version strings and map back to the internal integer version. + Used with methods that touch the playlog table. + """ + Versions = {} + def init_versions(cfg: ChuniConfig): + if len(ChuniRomVersion.Versions) > 0: + # dont bother with reinit + return + + # Build up a easily comparible list of versions. Used when deriving romVersion from the playlog + all_versions = { + 10: ChuniRomVersion("1.50.0"), + 9: ChuniRomVersion("1.45.0"), + 8: ChuniRomVersion("1.40.0"), + 7: ChuniRomVersion("1.35.0"), + 6: ChuniRomVersion("1.30.0"), + 5: ChuniRomVersion("1.25.0"), + 4: ChuniRomVersion("1.20.0"), + 3: ChuniRomVersion("1.15.0"), + 2: ChuniRomVersion("1.10.0"), + 1: ChuniRomVersion("1.05.0"), + 0: ChuniRomVersion("1.00.0") + } + + # add the versions from the config + for ver in range(11,999): + cfg_ver = cfg.version.version(ver) + if cfg_ver: + all_versions[ver] = ChuniRomVersion(cfg_ver["rom"]) + else: + break + + # sort it by version number for easy iteration + ChuniRomVersion.Versions = dict(sorted(all_versions.items())) + + def __init__(self, rom_version: str) -> None: + (major, minor, maint) = rom_version.split('.') + self.major = int(major) + self.minor = int(minor) + self.maint = int(maint) + self.version = rom_version + + def __str__(self) -> str: + return self.version + + def __eq__(self, other) -> bool: + return (self.major == other.major and + self.minor == other.minor and + self.maint == other.maint) + + def __lt__(self, other) -> bool: + return (self.major < other.major) or \ + (self.major == other.major and self.minor < other.minor) or \ + (self.major == other.major and self.minor == other.minor and self.maint < other.maint) + + def __gt__(self, other) -> bool: + return (self.major > other.major) or \ + (self.major == other.major and self.minor > other.minor) or \ + (self.major == other.major and self.minor == other.minor and self.maint > other.maint) + + def get_int_version(self) -> int: + """ + Used when displaying the playlog to walk backwards from the recorded romVersion to our internal version number. + This is effectively a workaround to avoid recording our internal version number along with the romVersion in the db at insert time. + """ + for ver,rom in ChuniRomVersion.Versions.items(): + # if the version matches exactly, great! + if self == rom: + return ver + + # If this isnt the last version, use the next as an upper bound + if ver + 1 < len(ChuniRomVersion.Versions): + if self > rom and self < ChuniRomVersion.Versions[ver + 1]: + # this version fits in the middle! It must be a revision of the version + # e.g. 2.15.00 vs 2.16.00 + return ver + else: + # this is the last version in the list. + # If its greate than this one and still the same major, this call it a match + if self.major == rom.major and self > rom: + return ver + + # Only way we get here is if it was a version that started with "0." which is def invalid + return -1 class ChuniScoreData(BaseData): async def get_courses(self, aime_id: int) -> Optional[Row]: @@ -190,45 +277,66 @@ class ChuniScoreData(BaseData): return None return result.fetchall() - async def get_playlogs_limited(self, aime_id: int, index: int, count: int) -> Optional[Row]: - sql = select(playlog).where(playlog.c.user == aime_id).order_by(playlog.c.id.desc()).limit(count).offset(index * count) + async def get_playlog_rom_versions_by_int_version(self, version: int, aime_id: int = -1) -> Optional[str]: + # Get a set of all romVersion values present + sql = select([playlog.c.romVersion]) + if aime_id != -1: + # limit results to a specific user + sql = sql.where(playlog.c.user == aime_id) + sql = sql.distinct() result = await self.execute(sql) if result is None: - self.logger.warning(f" aime_id {aime_id} has no playlog ") + return None + record_versions = result.fetchall() + + # for each romVersion recorded, check if it maps back the current version we are operating on + matching_rom_versions = [] + for v in record_versions: + if ChuniRomVersion(v[0]).get_int_version() == version: + matching_rom_versions += [v[0]] + + self.logger.debug(f"romVersions {matching_rom_versions} map to version {version}") + return matching_rom_versions + + async def get_playlogs_limited(self, aime_id: int, version: int, index: int, count: int) -> Optional[Row]: + # Get a list of all the recorded romVersions in the playlog + # for this user that map to the given version. + rom_versions = await self.get_playlog_rom_versions_by_int_version(version, aime_id) + if rom_versions is None: + return None + + # Query results that have the matching romVersions + sql = select(playlog).where((playlog.c.user == aime_id) & (playlog.c.romVersion.in_(rom_versions))).order_by(playlog.c.id.desc()).limit(count).offset(index * count) + + result = await self.execute(sql) + if result is None: + self.logger.info(f" aime_id {aime_id} has no playlog for version {version}") return None return result.fetchall() - async def get_user_playlogs_count(self, aime_id: int) -> Optional[Row]: - sql = select(func.count()).where(playlog.c.user == aime_id) + async def get_user_playlogs_count(self, aime_id: int, version: int) -> Optional[Row]: + # Get a list of all the recorded romVersions in the playlog + # for this user that map to the given version. + rom_versions = await self.get_playlog_rom_versions_by_int_version(version, aime_id) + if rom_versions is None: + return None + + # Query results that have the matching romVersions + sql = select(func.count()).where((playlog.c.user == aime_id) & (playlog.c.romVersion.in_(rom_versions))) + result = await self.execute(sql) if result is None: - self.logger.warning(f" aime_id {aime_id} has no playlog ") - return None + self.logger.info(f" aime_id {aime_id} has no playlog for version {version}") + return 0 return result.scalar() async def put_playlog(self, aime_id: int, playlog_data: Dict, version: int) -> Optional[int]: - # Calculate the ROM version that should be inserted into the DB, based on the version of the ggame being inserted - # We only need from Version 10 (Plost) and back, as newer versions include romVersion in their upsert - # This matters both for gameRankings, as well as a future DB update to keep version data separate - romVer = { - 10: "1.50.0", - 9: "1.45.0", - 8: "1.40.0", - 7: "1.35.0", - 6: "1.30.0", - 5: "1.25.0", - 4: "1.20.0", - 3: "1.15.0", - 2: "1.10.0", - 1: "1.05.0", - 0: "1.00.0" - } - playlog_data["user"] = aime_id playlog_data = self.fix_bools(playlog_data) + # If the romVersion is not in the data (Version 10 and earlier), look it up from our internal mapping if "romVersion" not in playlog_data: - playlog_data["romVersion"] = romVer.get(version, "1.00.0") + playlog_data["romVersion"] = ChuniRomVersion.Versions[version] sql = insert(playlog).values(**playlog_data) @@ -238,27 +346,13 @@ class ChuniScoreData(BaseData): return result.lastrowid async def get_rankings(self, version: int) -> Optional[List[Dict]]: - # Calculates the ROM version that should be fetched for rankings, based on the game version being retrieved - # This prevents tracks that are not accessible in your version from counting towards the 10 results - romVer = { - 15: "2.20%", - 14: "2.15%", - 13: "2.10%", - 12: "2.05%", - 11: "2.00%", - 10: "1.50%", - 9: "1.45%", - 8: "1.40%", - 7: "1.35%", - 6: "1.30%", - 5: "1.25%", - 4: "1.20%", - 3: "1.15%", - 2: "1.10%", - 1: "1.05%", - 0: "1.00%" - } - sql = select([playlog.c.musicId.label('id'), func.count(playlog.c.musicId).label('point')]).where((playlog.c.level != 4) & (playlog.c.romVersion.like(romVer.get(version, "%")))).group_by(playlog.c.musicId).order_by(func.count(playlog.c.musicId).desc()).limit(10) + # Get a list of all the recorded romVersions in the playlog for the given version + rom_versions = await self.get_playlog_rom_versions_by_int_version(version) + if rom_versions is None: + return None + + # Query results that have the matching romVersions + sql = select([playlog.c.musicId.label('id'), func.count(playlog.c.musicId).label('point')]).where((playlog.c.level != 4) & (playlog.c.romVersion.in_(rom_versions))).group_by(playlog.c.musicId).order_by(func.count(playlog.c.musicId).desc()).limit(10) result = await self.execute(sql) if result is None: diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index ed67b5d..5c96812 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -454,7 +454,7 @@ class ChuniStaticData(BaseData): return result.fetchone() async def get_song(self, music_id: int) -> Optional[Row]: - sql = music.select(music.c.id == music_id) + sql = music.select(music.c.songId == music_id) result = await self.execute(sql) if result is None: diff --git a/titles/chuni/templates/chuni_favorites.jinja b/titles/chuni/templates/chuni_favorites.jinja new file mode 100644 index 0000000..a386f6a --- /dev/null +++ b/titles/chuni/templates/chuni_favorites.jinja @@ -0,0 +1,55 @@ +{% extends "core/templates/index.jinja" %} +{% block content %} + +
+ {% include 'titles/chuni/templates/chuni_header.jinja' %} + {% if favorites_by_genre is defined and favorites_by_genre is not none %} +
+

{{ cur_version_name }}

+

Favorite Count: {{ favorites_count }}

+ {% for key, genre in favorites_by_genre.items() %} +

{{ key }}

+ {% for favorite in genre %} + +
+
+
+
+ +
+
+
{{ favorite.title }}
+
+
{{ favorite.artist }}
+

+
+ +
+
+
+
+
+ {% endfor %} + {% endfor %} +
+ {% endif %} +
+ +{% endblock content %} \ No newline at end of file diff --git a/titles/chuni/templates/chuni_header.jinja b/titles/chuni/templates/chuni_header.jinja index 76acdc5..6085d14 100644 --- a/titles/chuni/templates/chuni_header.jinja +++ b/titles/chuni/templates/chuni_header.jinja @@ -4,6 +4,7 @@
  • PROFILE
  • RATING
  • RECORD
  • +
  • FAVORITES
  • MUSICS
  • USER BOX
  • @@ -17,6 +18,8 @@ $('.nav-link[href="/game/chuni/playlog"]').addClass('active'); } else if (currentPath.startsWith('/game/chuni/rating')) { $('.nav-link[href="/game/chuni/rating"]').addClass('active'); + } else if (currentPath.startsWith('/game/chuni/favorites')) { + $('.nav-link[href="/game/chuni/favorites"]').addClass('active'); } else if (currentPath.startsWith('/game/chuni/musics')) { $('.nav-link[href="/game/chuni/musics"]').addClass('active'); } diff --git a/titles/chuni/templates/chuni_playlog.jinja b/titles/chuni/templates/chuni_playlog.jinja index fd30746..8e035f3 100644 --- a/titles/chuni/templates/chuni_playlog.jinja +++ b/titles/chuni/templates/chuni_playlog.jinja @@ -7,25 +7,36 @@ {% include 'titles/chuni/templates/chuni_header.jinja' %} {% if playlog is defined and playlog is not none %}
    -

    Playlog counts: {{ playlog_count }}

    +

    {{ cur_version_name }}

    +

    Playlog Count: {{ playlog_count }}

    {% set rankName = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'] %} {% set difficultyName = ['normal', 'hard', 'expert', 'master', 'ultimate'] %} {% for record in playlog %} +
    -
    +
    +

    +
    +
    {{ record.title }}

    {{ record.artist }}
    -
    +
    {{ record.raw.userPlayDate }}
    TRACK {{ record.raw.track }}
    -
    +
    + +
    +

    {{ record.raw.score }}

    {{ rankName[record.raw.rank] }}

    -
    +
    - + diff --git a/titles/chuni/templates/css/chuni_style.css b/titles/chuni/templates/css/chuni_style.css index 0900b9b..39c68b7 100644 --- a/titles/chuni/templates/css/chuni_style.css +++ b/titles/chuni/templates/css/chuni_style.css @@ -192,4 +192,21 @@ caption { 100% { transform: translateX(-100%); } +} + +.fav { + padding: 0; + padding-left: 4px; + background-color: transparent; + border: none; + cursor: pointer; +} + +.fav-set { + color: gold; +} + +.btn-fav-remove { + padding:10px; + width:100%; } \ No newline at end of file From ed5e7dc561a0931a76a5bdca6e155b51f08b6345 Mon Sep 17 00:00:00 2001 From: daydensteve Date: Wed, 25 Sep 2024 15:21:30 +0000 Subject: [PATCH 07/17] [chuni] Added truncation to long Title and Artist Name values on import (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I noticed the importer failing to import music 523 (Niji-iro no Flügel) from an omni pack due to the artist name being crazy long. To address this, I added truncation to max column value length for both the Title and Artist Name values. Considered doing this for the other 3 string fields as well but I can't imagine those ever being problematic. Import now succeeds with a warning generated about the truncation occurring Reviewed-on: https://gitea.tendokyu.moe/Hay1tsme/artemis/pulls/178 Co-authored-by: daydensteve Co-committed-by: daydensteve --- titles/chuni/read.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/titles/chuni/read.py b/titles/chuni/read.py index 12b0d9e..eebdf8b 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -7,6 +7,7 @@ from PIL import Image from core.config import CoreConfig from titles.chuni.database import ChuniData from titles.chuni.const import ChuniConstants +from titles.chuni.schema.static import music as MusicTable class ChuniReader(BaseReader): @@ -144,6 +145,9 @@ class ChuniReader(BaseReader): self.logger.warning(f"Failed to insert event {id}") async def read_music(self, music_dir: str, we_diff: str = "4") -> None: + max_title_len = MusicTable.columns["title"].type.length + max_artist_len = MusicTable.columns["artist"].type.length + for root, dirs, files in walk(music_dir): for dir in dirs: if path.exists(f"{root}/{dir}/Music.xml"): @@ -154,9 +158,15 @@ class ChuniReader(BaseReader): for name in xml_root.findall("name"): song_id = name.find("id").text title = name.find("str").text + if len(title) > max_title_len: + self.logger.warning(f"Truncating music {song_id} song title") + title = title[:max_title_len] for artistName in xml_root.findall("artistName"): artist = artistName.find("str").text + if len(artist) > max_artist_len: + self.logger.warning(f"Truncating music {song_id} artist name") + artist = artist[:max_artist_len] for genreNames in xml_root.findall("genreNames"): for list_ in genreNames.findall("list"): From 3843ac6eb14b130cb8819c318c41e110344906ee Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Thu, 3 Oct 2024 19:32:17 +0000 Subject: [PATCH 08/17] mai2: calc GetGameRanking result --- titles/mai2/base.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index b041028..9d85857 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -1,3 +1,4 @@ +import pymysql from datetime import datetime, timedelta from typing import Any, Dict, List import logging @@ -76,7 +77,40 @@ class Mai2Base: } async def handle_get_game_ranking_api_request(self, data: Dict) -> Dict: - return {"length": 0, "gameRankingList": []} + conn = pymysql.connect( + host=self.core_config.database.host, + port=self.core_config.database.port, + user=self.core_config.database.username, + password=self.core_config.database.password, + database=self.core_config.database.name, + charset='utf8mb4' + ) + try: + cursor = conn.cursor() + + query = """ + SELECT musicid AS id, COUNT(*) AS point + FROM mai2_playlog + GROUP BY musicid + ORDER BY point DESC + LIMIT 100 + """ + cursor.execute(query) + + results = cursor.fetchall() + ranking_list = [{"id": row[0], "point": row[1], "userName": ""} for row in results] + output = { + "type": 1, + "gameRankingList": ranking_list, + "gameRankingInstantList": None + } + + cursor.close() + conn.close() + return output + + except Exception as e: + return {'length': 0, 'gameRankingList': []} async def handle_get_game_tournament_info_api_request(self, data: Dict) -> Dict: # TODO: Tournament support From 58ae491a8ccd76cae5682b150b2da89bdc3b7295 Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Thu, 3 Oct 2024 19:47:36 +0000 Subject: [PATCH 09/17] add pymysql to requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fe5b4ef..72d9844 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,4 +21,5 @@ starlette asyncio uvicorn alembic -python-multipart \ No newline at end of file +python-multipart +pymysql \ No newline at end of file From 0cef797a8a74c6a895fbfaab8035e0bd57a6b65c Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Sun, 6 Oct 2024 03:47:10 -0400 Subject: [PATCH 10/17] mai2: rework photo uploads, relates to #67 --- .../versions/d8cd1fa04c2a_mai2_add_photos.py | 38 +++++ titles/mai2/base.py | 61 ++++---- titles/mai2/frontend.py | 130 +++++++++++++++++- titles/mai2/schema/profile.py | 54 +++++++- titles/mai2/templates/mai2_header.jinja | 3 + titles/mai2/templates/mai2_photos.jinja | 28 ++++ 6 files changed, 272 insertions(+), 42 deletions(-) create mode 100644 core/data/alembic/versions/d8cd1fa04c2a_mai2_add_photos.py create mode 100644 titles/mai2/templates/mai2_photos.jinja diff --git a/core/data/alembic/versions/d8cd1fa04c2a_mai2_add_photos.py b/core/data/alembic/versions/d8cd1fa04c2a_mai2_add_photos.py new file mode 100644 index 0000000..312a127 --- /dev/null +++ b/core/data/alembic/versions/d8cd1fa04c2a_mai2_add_photos.py @@ -0,0 +1,38 @@ +"""mai2_add_photos + +Revision ID: d8cd1fa04c2a +Revises: 54a84103b84e +Create Date: 2024-10-06 03:09:15.959817 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'd8cd1fa04c2a' +down_revision = '54a84103b84e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('mai2_user_photo', + sa.Column('id', sa.VARCHAR(length=36), nullable=False), + sa.Column('user', sa.Integer(), nullable=False), + sa.Column('playlog_num', sa.INTEGER(), nullable=False), + sa.Column('track_num', sa.INTEGER(), nullable=False), + sa.Column('when_upload', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user'], ['aime_user.id'], onupdate='cascade', ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user', 'playlog_num', 'track_num', name='mai2_user_photo_uk'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('mai2_user_photo') + # ### end Alembic commands ### diff --git a/titles/mai2/base.py b/titles/mai2/base.py index b041028..cb74206 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Any, Dict, List import logging from base64 import b64decode -from os import path, stat, remove +from os import path, stat, remove, mkdir, access, W_OK from PIL import ImageFile from random import randint @@ -866,46 +866,33 @@ class Mai2Base: self.logger.warning(f"Incorrect data size after decoding (Expected 10240, got {len(photo_chunk)})") return {'returnCode': 0, 'apiName': 'UploadUserPhotoApi'} - out_name = f"{self.game_config.uploads.photos_dir}/{user_id}_{playlog_id}_{track_num}" + photo_data = await self.data.profile.get_user_photo_by_user_playlog_track(user_id, playlog_id, track_num) + + if not photo_data: + photo_id = await self.data.profile.put_user_photo(user_id, playlog_id, track_num) + else: + photo_id = photo_data['id'] - if not path.exists(f"{out_name}.bin") and div_num != 0: - self.logger.warning(f"Out of order photo upload (div_num {div_num})") - return {'returnCode': 0, 'apiName': 'UploadUserPhotoApi'} - - if path.exists(f"{out_name}.bin") and div_num == 0: - self.logger.warning(f"Duplicate file upload") + out_folder = f"{self.game_config.uploads.photos_dir}/{photo_id}" + out_file = f"{out_folder}/{div_num}_{div_len - 1}.bin" + + if not path.exists(out_folder): + mkdir(out_folder) + + if not access(out_folder, W_OK): + self.logger.error(f"Cannot access {out_folder}") return {'returnCode': 0, 'apiName': 'UploadUserPhotoApi'} - elif path.exists(f"{out_name}.bin"): - fstats = stat(f"{out_name}.bin") - if fstats.st_size != 10240 * div_num: - self.logger.warning(f"Out of order photo upload (trying to upload div {div_num}, expected div {fstats.st_size / 10240} for file sized {fstats.st_size} bytes)") + if path.exists(out_file): + self.logger.warning(f"Photo chunk {out_file} already exists, skipping") + + else: + with open(out_file, "wb") as f: + written = f.write(photo_chunk) + + if written != len(photo_chunk): + self.logger.error(f"Writing {out_file} failed! Wrote {written} bytes, expected {photo_chunk} bytes") return {'returnCode': 0, 'apiName': 'UploadUserPhotoApi'} - - try: - with open(f"{out_name}.bin", "ab") as f: - f.write(photo_chunk) - - except Exception: - self.logger.error(f"Failed writing to {out_name}.bin") - return {'returnCode': 0, 'apiName': 'UploadUserPhotoApi'} - - if div_num + 1 == div_len and path.exists(f"{out_name}.bin"): - try: - p = ImageFile.Parser() - with open(f"{out_name}.bin", "rb") as f: - p.feed(f.read()) - - im = p.close() - im.save(f"{out_name}.jpeg") - except Exception: - self.logger.error(f"File {out_name}.bin failed image validation") - - try: - remove(f"{out_name}.bin") - - except Exception: - self.logger.error(f"Failed to delete {out_name}.bin, please remove it manually") return {'returnCode': ret_code, 'apiName': 'UploadUserPhotoApi'} diff --git a/titles/mai2/frontend.py b/titles/mai2/frontend.py index 976e2c4..f1f961a 100644 --- a/titles/mai2/frontend.py +++ b/titles/mai2/frontend.py @@ -1,11 +1,14 @@ from typing import List from starlette.routing import Route, Mount from starlette.requests import Request -from starlette.responses import Response, RedirectResponse -from os import path +from starlette.responses import Response, RedirectResponse, FileResponse +from os import path, walk, remove import yaml import jinja2 -from datetime import datetime +from datetime import datetime, timedelta +from PIL import ImageFile +import re +import shutil from core.frontend import FE_Base, UserSession, PermissionOffset from core.config import CoreConfig @@ -31,7 +34,8 @@ class Mai2Frontend(FE_Base): Route("/", self.render_GET, methods=['GET']), Mount("/playlog", routes=[ Route("/", self.render_GET_playlog, methods=['GET']), - Route("/{index}", self.render_GET_playlog, methods=['GET']), + Route("/{index:int}", self.render_GET_playlog, methods=['GET']), + Route("/photos", self.render_GET_photos, methods=['GET']), ]), Mount("/events", routes=[ Route("/", self.render_events, methods=['GET']), @@ -41,6 +45,7 @@ class Mai2Frontend(FE_Base): ]), Route("/update.name", self.update_name, methods=['POST']), Route("/version.change", self.version_change, methods=['POST']), + Route("/photo/{photo_id}", self.get_photo, methods=['GET']), ] async def render_GET(self, request: Request) -> bytes: @@ -140,6 +145,50 @@ class Mai2Frontend(FE_Base): else: return RedirectResponse("/gate/", 303) + async def render_GET_photos(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/mai2/templates/mai2_photos.jinja" + ) + usr_sesh = self.validate_session(request) + if not usr_sesh: + usr_sesh = UserSession() + + if usr_sesh.user_id > 0: + if usr_sesh.maimai_version < 0: + return RedirectResponse("/game/mai2/", 303) + + photos = await self.data.profile.get_user_photos_by_user(usr_sesh.user_id) + + photos_fixed = [] + for photo in photos: + if datetime.now().timestamp() > (photo['when_upload'] + timedelta(days=7)).timestamp(): + await self.data.profile.delete_user_photo_by_id(photo['id']) + + if path.exists(f"{self.game_cfg.uploads.photos_dir}/{photo['id']}.jpeg"): + remove(f"{self.game_cfg.uploads.photos_dir}/{photo['id']}.jpeg") + + if path.exists(f"{self.game_cfg.uploads.photos_dir}/{photo['id']}"): + shutil.rmtree(f"{self.game_cfg.uploads.photos_dir}/{photo['id']}") + + continue + + photos_fixed.append({ + "id": photo['id'], + "playlog_num": photo['playlog_num'], + "track_num": photo['track_num'], + "when_upload": photo['when_upload'], + }) + + return Response(template.render( + title=f"{self.core_config.server.name} | {self.nav_name}", + game_list=self.environment.globals["game_list"], + sesh=vars(usr_sesh), + photos=photos_fixed, + expire_days=7, + ), media_type="text/html; charset=utf-8") + else: + return RedirectResponse("/gate/", 303) + async def update_name(self, request: Request) -> bytes: usr_sesh = self.validate_session(request) if not usr_sesh: @@ -299,3 +348,76 @@ class Mai2Frontend(FE_Base): await self.data.static.update_event_by_id(int(event_id), new_enabled, new_start_date) return RedirectResponse("/game/mai2/events/?s=1", 303) + + async def get_photo(self, request: Request) -> RedirectResponse: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate/", 303) + + photo_jpeg = request.path_params.get("photo_id", None) + if not photo_jpeg: + return Response(status_code=400) + + matcher = re.match(r"^([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}).jpeg$", photo_jpeg) + if not matcher: + return Response(status_code=400) + + photo_id = matcher.groups()[0] + photo_info = await self.data.profile.get_user_photo_by_id(photo_id) + if not photo_info: + return Response(status_code=404) + + if photo_info["user"] != usr_sesh.user_id: + return Response(status_code=403) + + out_folder = f"{self.game_cfg.uploads.photos_dir}/{photo_id}" + + if datetime.now().timestamp() > (photo_info['when_upload'] + timedelta(days=7)).timestamp(): + await self.data.profile.delete_user_photo_by_id(photo_info['id']) + if path.exists(f"{out_folder}.jpeg"): + remove(f"{out_folder}.jpeg") + + if path.exists(f"{out_folder}"): + shutil.rmtree(out_folder) + + return Response(status_code=404) + + if path.exists(f"{out_folder}"): + print("path exists") + max_idx = 0 + p = ImageFile.Parser() + for _, _, files in walk("out_folder"): + if not files: + break + + matcher = re.match("^(\d+)_(\d+)$", files[0]) + if not matcher: + break + + max_idx = int(matcher.groups()[1]) + + if max_idx + 1 != len(files): + self.logger.error(f"Expected {max_idx + 1} files, found {len(files)}") + max_idx = 0 + break + + if max_idx == 0: + return Response(status_code=500) + + for i in range(max_idx + 1): + with open(f"{out_folder}/{i}_{max_idx}", "rb") as f: + p.feed(f.read()) + try: + im = p.close() + im.save(f"{out_folder}.jpeg") + + except Exception as e: + self.logger.error(f"{photo_id} failed PIL validation! - {e}") + + shutil.rmtree(out_folder) + + if path.exists(f"{out_folder}.jpeg"): + print(f"{out_folder}.jpeg exists") + return FileResponse(f"{out_folder}.jpeg") + + return Response(status_code=404) diff --git a/titles/mai2/schema/profile.py b/titles/mai2/schema/profile.py index 3ff85d2..ede0adf 100644 --- a/titles/mai2/schema/profile.py +++ b/titles/mai2/schema/profile.py @@ -1,9 +1,10 @@ from core.data.schema import BaseData, metadata from titles.mai2.const import Mai2Constants +from uuid import uuid4 from typing import Optional, Dict, List from sqlalchemy import Table, Column, UniqueConstraint, PrimaryKeyConstraint, and_ -from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON, BigInteger, SmallInteger +from sqlalchemy.types import Integer, String, TIMESTAMP, Boolean, JSON, BigInteger, SmallInteger, VARCHAR, INTEGER from sqlalchemy.schema import ForeignKey from sqlalchemy.sql import func, select from sqlalchemy.engine import Row @@ -529,6 +530,22 @@ intimacy = Table( mysql_charset="utf8mb4", ) +photo = Table( # end-of-credit memorial photos, NOT user portraits + "mai2_user_photo", + metadata, + Column("id", VARCHAR(36), primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("playlog_num", INTEGER, nullable=False), + Column("track_num", INTEGER, nullable=False), + Column("when_upload", TIMESTAMP, nullable=False, server_default=func.now()), + UniqueConstraint("user", "playlog_num", "track_num", name="mai2_user_photo_uk"), + mysql_charset="utf8mb4", +) + class Mai2ProfileData(BaseData): async def get_all_profile_versions(self, user_id: int) -> Optional[List[Row]]: result = await self.execute(detail.select(detail.c.user == user_id)) @@ -945,6 +962,41 @@ class Mai2ProfileData(BaseData): self.logger.error(f"Failed to update intimacy for user {user_id} and partner {partner_id}!") + async def put_user_photo(self, user_id: int, playlog_num: int, track_num: int) -> Optional[str]: + photo_id = str(uuid4()) + sql = insert(photo).values( + id = photo_id, + user = user_id, + playlog_num = playlog_num, + track_num = track_num, + ) + + conflict = sql.on_duplicate_key_update(user = user_id) + + result = await self.execute(conflict) + if result: + return photo_id + + async def get_user_photo_by_id(self, photo_id: str) -> Optional[Row]: + result = await self.execute(photo.select(photo.c.id.like(photo_id))) + if result: + return result.fetchone() + + async def get_user_photo_by_user_playlog_track(self, user_id: int, playlog_num: int, track_num: int) -> Optional[Row]: + result = await self.execute(photo.select(and_(and_(photo.c.user == user_id, photo.c.playlog_num == playlog_num), photo.c.track_num == track_num))) + if result: + return result.fetchone() + + async def get_user_photos_by_user(self, user_id: int) -> Optional[List[Row]]: + result = await self.execute(photo.select(photo.c.user == user_id)) + if result: + return result.fetchall() + + async def delete_user_photo_by_id(self, photo_id: str) -> Optional[List[Row]]: + result = await self.execute(photo.delete(photo.c.id.like(photo_id))) + if not result: + self.logger.error(f"Failed to delete photo {photo_id}") + async def update_name(self, user_id: int, new_name: str) -> bool: sql = detail.update(detail.c.user == user_id).values( userName=new_name diff --git a/titles/mai2/templates/mai2_header.jinja b/titles/mai2/templates/mai2_header.jinja index f226fbe..7e6757f 100644 --- a/titles/mai2/templates/mai2_header.jinja +++ b/titles/mai2/templates/mai2_header.jinja @@ -3,6 +3,7 @@
    • PROFILE
    • RECORD
    • +
    • PHOTOS
    • {% if sesh is defined and sesh is not none and "{:08b}".format(sesh.permissions)[4] == "1" %}
    • EVENTS
    • {% endif %} @@ -13,6 +14,8 @@ var currentPath = window.location.pathname; if (currentPath === '/game/mai2/') { $('.nav-link[href="/game/mai2/"]').addClass('active'); + } else if (currentPath.startsWith('/game/mai2/playlog/photos')) { + $('.nav-link[href="/game/mai2/playlog/photos"]').addClass('active'); } else if (currentPath.startsWith('/game/mai2/playlog/')) { $('.nav-link[href="/game/mai2/playlog/"]').addClass('active'); } {% if sesh is defined and sesh is not none and "{:08b}".format(sesh.permissions)[4] == "1" %}else if (currentPath.startsWith('/game/mai2/events/')) { diff --git a/titles/mai2/templates/mai2_photos.jinja b/titles/mai2/templates/mai2_photos.jinja new file mode 100644 index 0000000..f112016 --- /dev/null +++ b/titles/mai2/templates/mai2_photos.jinja @@ -0,0 +1,28 @@ +{% extends "core/templates/index.jinja" %} +{% block content %} + +
      + {% include 'titles/mai2/templates/mai2_header.jinja' %} +
      +

      Memorial Photos

      Photos expire after {{ expire_days }} days + {% if photos is defined and photos is not none and photos|length > 0 %} + {% for photo in photos %} +
      + Playlog #{{ photo.playlog_num }} | Track #{{ photo.track_num }} +
      + {{ photo.when_upload }} +
      + +
      +
      + {% endfor %} + {% else %} +
      + No photos +
      + {% endif %} +
      +
      +{% endblock content %} \ No newline at end of file From 451754cf3cde6fe10e622fb2e6a0ea460aee9b89 Mon Sep 17 00:00:00 2001 From: Kevin Trocolli Date: Sun, 6 Oct 2024 16:09:09 -0400 Subject: [PATCH 11/17] sao: fix my store --- titles/sao/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/titles/sao/base.py b/titles/sao/base.py index 44a7801..759d35b 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -688,6 +688,8 @@ class SaoBase: if profile_data['my_shop']: ac = await self.data.arcade.get_arcade(profile_data['my_shop']) if ac: + # TODO: account for machine override + resp.user_basic_data[0].my_store_id = f"{ac['country']}0{ac['id']:04d}" resp.user_basic_data[0].my_store_name = ac['name'] return resp.make() From 033c1aa776b7874b6e534efd664f0b7010271e68 Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Fri, 11 Oct 2024 16:06:17 +0000 Subject: [PATCH 12/17] =?UTF-8?q?Update=20=E5=8D=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- titles/mai2/base.py | 43 ++++++++++++++++++------------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 2d0b686..d498fcf 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -77,40 +77,33 @@ class Mai2Base: } async def handle_get_game_ranking_api_request(self, data: Dict) -> Dict: - conn = pymysql.connect( - host=self.core_config.database.host, - port=self.core_config.database.port, - user=self.core_config.database.username, - password=self.core_config.database.password, - database=self.core_config.database.name, - charset='utf8mb4' - ) try: - cursor = conn.cursor() + playlogs = await self.data.score.get_playlogs(user_id=None) + ranking_list = [] - query = """ - SELECT musicid AS id, COUNT(*) AS point - FROM mai2_playlog - GROUP BY musicid - ORDER BY point DESC - LIMIT 100 - """ - cursor.execute(query) + if not playlogs: + self.logger.warning("No playlogs found.") + return {"length": 0, "gameRankingList": []} - results = cursor.fetchall() - ranking_list = [{"id": row[0], "point": row[1], "userName": ""} for row in results] - output = { + music_count = {} + for log in playlogs: + music_id = log.musicId + music_count[music_id] = music_count.get(music_id, 0) + 1 + + sorted_music = sorted(music_count.items(), key=lambda item: item[1], reverse=True) + + for music_id, count in sorted_music[:100]: + ranking_list.append({"id": music_id, "point": count, "userName": ""}) + + return { "type": 1, "gameRankingList": ranking_list, "gameRankingInstantList": None } - cursor.close() - conn.close() - return output - except Exception as e: - return {'length': 0, 'gameRankingList': []} + self.logger.error(f"Error while getting game ranking: {e}") + return {"length": 0, "gameRankingList": []} async def handle_get_game_tournament_info_api_request(self, data: Dict) -> Dict: # TODO: Tournament support From 29f4a6a696c03170e51a3eb14b0a643f9a2386dc Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Fri, 11 Oct 2024 16:08:15 +0000 Subject: [PATCH 13/17] revert 033c1aa776b7874b6e534efd664f0b7010271e68 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revert Update 卖 --- titles/mai2/base.py | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index d498fcf..2d0b686 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -77,33 +77,40 @@ class Mai2Base: } async def handle_get_game_ranking_api_request(self, data: Dict) -> Dict: + conn = pymysql.connect( + host=self.core_config.database.host, + port=self.core_config.database.port, + user=self.core_config.database.username, + password=self.core_config.database.password, + database=self.core_config.database.name, + charset='utf8mb4' + ) try: - playlogs = await self.data.score.get_playlogs(user_id=None) - ranking_list = [] + cursor = conn.cursor() - if not playlogs: - self.logger.warning("No playlogs found.") - return {"length": 0, "gameRankingList": []} + query = """ + SELECT musicid AS id, COUNT(*) AS point + FROM mai2_playlog + GROUP BY musicid + ORDER BY point DESC + LIMIT 100 + """ + cursor.execute(query) - music_count = {} - for log in playlogs: - music_id = log.musicId - music_count[music_id] = music_count.get(music_id, 0) + 1 - - sorted_music = sorted(music_count.items(), key=lambda item: item[1], reverse=True) - - for music_id, count in sorted_music[:100]: - ranking_list.append({"id": music_id, "point": count, "userName": ""}) - - return { + results = cursor.fetchall() + ranking_list = [{"id": row[0], "point": row[1], "userName": ""} for row in results] + output = { "type": 1, "gameRankingList": ranking_list, "gameRankingInstantList": None } + cursor.close() + conn.close() + return output + except Exception as e: - self.logger.error(f"Error while getting game ranking: {e}") - return {"length": 0, "gameRankingList": []} + return {'length': 0, 'gameRankingList': []} async def handle_get_game_tournament_info_api_request(self, data: Dict) -> Dict: # TODO: Tournament support From 398fa9059d3f14900721678934ad546a76061481 Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Fri, 11 Oct 2024 16:09:53 +0000 Subject: [PATCH 14/17] Update mai2/base.py using the ORM --- titles/mai2/base.py | 43 ++++++++++++++++++------------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index 2d0b686..d498fcf 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -77,40 +77,33 @@ class Mai2Base: } async def handle_get_game_ranking_api_request(self, data: Dict) -> Dict: - conn = pymysql.connect( - host=self.core_config.database.host, - port=self.core_config.database.port, - user=self.core_config.database.username, - password=self.core_config.database.password, - database=self.core_config.database.name, - charset='utf8mb4' - ) try: - cursor = conn.cursor() + playlogs = await self.data.score.get_playlogs(user_id=None) + ranking_list = [] - query = """ - SELECT musicid AS id, COUNT(*) AS point - FROM mai2_playlog - GROUP BY musicid - ORDER BY point DESC - LIMIT 100 - """ - cursor.execute(query) + if not playlogs: + self.logger.warning("No playlogs found.") + return {"length": 0, "gameRankingList": []} - results = cursor.fetchall() - ranking_list = [{"id": row[0], "point": row[1], "userName": ""} for row in results] - output = { + music_count = {} + for log in playlogs: + music_id = log.musicId + music_count[music_id] = music_count.get(music_id, 0) + 1 + + sorted_music = sorted(music_count.items(), key=lambda item: item[1], reverse=True) + + for music_id, count in sorted_music[:100]: + ranking_list.append({"id": music_id, "point": count, "userName": ""}) + + return { "type": 1, "gameRankingList": ranking_list, "gameRankingInstantList": None } - cursor.close() - conn.close() - return output - except Exception as e: - return {'length': 0, 'gameRankingList': []} + self.logger.error(f"Error while getting game ranking: {e}") + return {"length": 0, "gameRankingList": []} async def handle_get_game_tournament_info_api_request(self, data: Dict) -> Dict: # TODO: Tournament support From a673d9dabd4cde016a9239ec45b5ffab37246364 Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Fri, 11 Oct 2024 16:12:53 +0000 Subject: [PATCH 15/17] Delete unused dependency --- titles/mai2/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/titles/mai2/base.py b/titles/mai2/base.py index d498fcf..cde1e1e 100644 --- a/titles/mai2/base.py +++ b/titles/mai2/base.py @@ -1,4 +1,3 @@ -import pymysql from datetime import datetime, timedelta from typing import Any, Dict, List import logging From 598e4aad76dbc915fae47ff2c61b469732c20c9b Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Fri, 11 Oct 2024 16:16:40 +0000 Subject: [PATCH 16/17] Update mai2/schema/score.py to support new handle_get_game_ranking --- titles/mai2/schema/score.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/titles/mai2/schema/score.py b/titles/mai2/schema/score.py index f62466a..e376216 100644 --- a/titles/mai2/schema/score.py +++ b/titles/mai2/schema/score.py @@ -396,8 +396,11 @@ class Mai2ScoreData(BaseData): return result.fetchall() async def get_playlogs(self, user_id: int, idx: int = 0, limit: int = 0) -> Optional[List[Row]]: - sql = playlog.select(playlog.c.user == user_id) - + if user_id is not None: + sql = playlog.select(playlog.c.user == user_id) + else: + sql = playlog.select() + if limit: sql = sql.limit(limit) if idx: From b6e7e0973b0db16bc0ac1d77657934c7f34f245c Mon Sep 17 00:00:00 2001 From: SoulGateKey Date: Fri, 11 Oct 2024 16:19:07 +0000 Subject: [PATCH 17/17] Delete unused dependency --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 72d9844..fe5b4ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,5 +21,4 @@ starlette asyncio uvicorn alembic -python-multipart -pymysql \ No newline at end of file +python-multipart \ No newline at end of file
    JUSTICE CRITIALJUSTICE CRITICAL {{ record.raw.judgeCritical + record.raw.judgeHeaven }}