Merge remote-tracking branch 'origin/develop' into sgkdev
# Conflicts: # titles/mai2/schema/profile.py
This commit is contained in:
commit
52b397f31f
38
core/data/alembic/versions/d8cd1fa04c2a_mai2_add_photos.py
Normal file
38
core/data/alembic/versions/d8cd1fa04c2a_mai2_add_photos.py
Normal file
@ -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 ###
|
@ -26,11 +26,14 @@ 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)
|
||||
|
||||
|
||||
# Supported Games
|
||||
@ -293,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
|
||||
@ -651,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
|
||||
|
||||
@ -797,3 +828,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.
|
||||
|
@ -1,5 +1,5 @@
|
||||
server:
|
||||
enable: True
|
||||
enable: False
|
||||
loglevel: "info"
|
||||
hostname: ""
|
||||
news: ""
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
BIN
titles/chuni/img/jacket/unknown.png
Normal file
BIN
titles/chuni/img/jacket/unknown.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
@ -2,10 +2,12 @@ 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
|
||||
from titles.chuni.const import ChuniConstants
|
||||
from titles.chuni.schema.static import music as MusicTable
|
||||
|
||||
|
||||
class ChuniReader(BaseReader):
|
||||
@ -143,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"):
|
||||
@ -153,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"):
|
||||
@ -164,6 +175,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"):
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,76 +277,82 @@ 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)
|
||||
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
|
||||
|
||||
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:
|
||||
|
@ -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:
|
||||
|
55
titles/chuni/templates/chuni_favorites.jinja
Normal file
55
titles/chuni/templates/chuni_favorites.jinja
Normal file
@ -0,0 +1,55 @@
|
||||
{% extends "core/templates/index.jinja" %}
|
||||
{% block content %}
|
||||
<style>
|
||||
{% include 'titles/chuni/templates/css/chuni_style.css' %}
|
||||
</style>
|
||||
<div class="container">
|
||||
{% include 'titles/chuni/templates/chuni_header.jinja' %}
|
||||
{% if favorites_by_genre is defined and favorites_by_genre is not none %}
|
||||
<div class="row">
|
||||
<h1 style="text-align: center;">{{ cur_version_name }}</h1>
|
||||
<h4 style="text-align: center;">Favorite Count: {{ favorites_count }}</h4>
|
||||
{% for key, genre in favorites_by_genre.items() %}
|
||||
<h2 style="text-align: center; padding-top: 32px">{{ key }}</h2>
|
||||
{% for favorite in genre %}
|
||||
<form id="fav_{{ favorite.idx }}" action="/game/chuni/update.favorite_music_favorites" method="post" style="display: none;">
|
||||
<input class="form-control" form="fav_{{ favorite.idx }}" id="musicId" name="musicId" type="hidden" value="{{ favorite.favId }}">
|
||||
<input class="form-control" form="fav_{{ favorite.idx }}" id="isAdd" name="isAdd" type="hidden" value="0">
|
||||
</form>
|
||||
<div class="col-lg-6 mt-3">
|
||||
<div class="card bg-card rounded card-hover">
|
||||
<div class="card-body row">
|
||||
<div class="col-3" style="text-align: center;">
|
||||
<img src="img/jacket/{{ favorite.jacket }}" width="100%">
|
||||
</div>
|
||||
<div class="col scrolling-text">
|
||||
<h5 class="card-text"> {{ favorite.title }} </h5>
|
||||
<br>
|
||||
<h6 class="card-text"> {{ favorite.artist }} </h6>
|
||||
<br><br>
|
||||
<div style="text-align: right;">
|
||||
<input type=submit class="btn btn-secondary btn-fav-remove" type="button" form="fav_{{ favorite.idx }}" value="Remove">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('.scrolling-text p, .scrolling-text h1, .scrolling-text h2, .scrolling-text h3, .scrolling-text h4, .scrolling-text h5, .scrolling-text h6').each(function () {
|
||||
var parentWidth = $(this).parent().width();
|
||||
var elementWidth = $(this).outerWidth();
|
||||
var elementWidthWithPadding = $(this).outerWidth(true);
|
||||
|
||||
if (elementWidthWithPadding > parentWidth) {
|
||||
$(this).addClass('scrolling');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock content %}
|
@ -4,6 +4,7 @@
|
||||
<li><a class="nav-link" href="/game/chuni">PROFILE</a></li>
|
||||
<li><a class="nav-link" href="/game/chuni/rating">RATING</a></li>
|
||||
<li><a class="nav-link" href="/game/chuni/playlog">RECORD</a></li>
|
||||
<li><a class="nav-link" href="/game/chuni/favorites">FAVORITES</a></li>
|
||||
<li><a class="nav-link" href="/game/chuni/musics">MUSICS</a></li>
|
||||
<li><a class="nav-link" href="/game/chuni/userbox">USER BOX</a></li>
|
||||
</ul>
|
||||
@ -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');
|
||||
}
|
||||
|
@ -7,25 +7,36 @@
|
||||
{% include 'titles/chuni/templates/chuni_header.jinja' %}
|
||||
{% if playlog is defined and playlog is not none %}
|
||||
<div class="row">
|
||||
<h4 style="text-align: center;">Playlog counts: {{ playlog_count }}</h4>
|
||||
<h1 style="text-align: center;">{{ cur_version_name }}</h1>
|
||||
<h4 style="text-align: center;">Playlog Count: {{ playlog_count }}</h4>
|
||||
{% 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 %}
|
||||
<form id="fav_{{ record.idx }}" action="/game/chuni/update.favorite_music_playlog" method="post" style="display: none;">
|
||||
<input class="form-control" form="fav_{{ record.idx }}" id="musicId" name="musicId" type="hidden" value="{{ record.musicId }}">
|
||||
<input class="form-control" form="fav_{{ record.idx }}" id="isAdd" name="isAdd" type="hidden" value="{{ 0 if record.isFav else 1 }}">
|
||||
</form>
|
||||
<div class="col-lg-6 mt-3">
|
||||
<div class="card bg-card rounded card-hover">
|
||||
<div class="card-header row">
|
||||
<div class="col-8 scrolling-text">
|
||||
<div class="col-auto fav" title="{{ ('Remove' if record.isFav else 'Add') + ' Favorite'}}">
|
||||
<h1><input type=submit class="fav {{ 'fav-set' if record.isFav else '' }}" type="button" form="fav_{{ record.idx }}" value="{{ '★' if record.isFav else '☆' }} "></h1>
|
||||
</div>
|
||||
<div class="col scrolling-text">
|
||||
<h5 class="card-text"> {{ record.title }} </h5>
|
||||
<br>
|
||||
<h6 class="card-text"> {{ record.artist }} </h6>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="col-auto">
|
||||
<h6 class="card-text">{{ record.raw.userPlayDate }}</h6>
|
||||
<h6 class="card-text">TRACK {{ record.raw.track }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body row">
|
||||
<div class="col-3" style="text-align: center;">
|
||||
<div class="col-sm" style="text-align: center;">
|
||||
<img src="../img/jacket/{{ record.jacket }}" width="100%">
|
||||
</div>
|
||||
<div class="col" style="text-align: center;">
|
||||
<h4 class="card-text">{{ record.raw.score }}</h4>
|
||||
<h2>{{ rankName[record.raw.rank] }}</h2>
|
||||
<h6
|
||||
@ -33,10 +44,10 @@
|
||||
{{ difficultyName[record.raw.level] }}  {{ record.difficultyNum }}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-6" style="text-align: center;">
|
||||
<div class="col-4" style="text-align: center;">
|
||||
<table class="table-small table-rowdistinc">
|
||||
<tr>
|
||||
<td>JUSTICE CRITIAL</td>
|
||||
<td>JUSTICE CRITICAL</td>
|
||||
<td>
|
||||
{{ record.raw.judgeCritical + record.raw.judgeHeaven }}
|
||||
</td>
|
||||
|
@ -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%;
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
@ -76,7 +76,33 @@ class Mai2Base:
|
||||
}
|
||||
|
||||
async def handle_get_game_ranking_api_request(self, data: Dict) -> Dict:
|
||||
return {"length": 0, "gameRankingList": []}
|
||||
try:
|
||||
playlogs = await self.data.score.get_playlogs(user_id=None)
|
||||
ranking_list = []
|
||||
|
||||
if not playlogs:
|
||||
self.logger.warning("No playlogs found.")
|
||||
return {"length": 0, "gameRankingList": []}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
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
|
||||
@ -866,46 +892,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'}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
@ -400,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:
|
||||
|
@ -3,6 +3,7 @@
|
||||
<ul class="mai2-navi">
|
||||
<li><a class="nav-link" href="/game/mai2/">PROFILE</a></li>
|
||||
<li><a class="nav-link" href="/game/mai2/playlog/">RECORD</a></li>
|
||||
<li><a class="nav-link" href="/game/mai2/playlog/photos">PHOTOS</a></li>
|
||||
{% if sesh is defined and sesh is not none and "{:08b}".format(sesh.permissions)[4] == "1" %}
|
||||
<li><a class="nav-link" href="/game/mai2/events/">EVENTS</a></li>
|
||||
{% 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/')) {
|
||||
|
28
titles/mai2/templates/mai2_photos.jinja
Normal file
28
titles/mai2/templates/mai2_photos.jinja
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends "core/templates/index.jinja" %}
|
||||
{% block content %}
|
||||
<style>
|
||||
{% include 'titles/mai2/templates/css/mai2_style.css' %}
|
||||
</style>
|
||||
<div class="container">
|
||||
{% include 'titles/mai2/templates/mai2_header.jinja' %}
|
||||
<div class="row">
|
||||
<h4 style="text-align: center;">Memorial Photos</h4><sub style="text-align: center;">Photos expire after {{ expire_days }} days</sub>
|
||||
{% if photos is defined and photos is not none and photos|length > 0 %}
|
||||
{% for photo in photos %}
|
||||
<div class="col-lg-6 mt-3" style="text-align: center;">
|
||||
Playlog #{{ photo.playlog_num }} | Track #{{ photo.track_num }}
|
||||
<br>
|
||||
{{ photo.when_upload }}
|
||||
<br>
|
||||
<img src="/game/mai2/photo/{{ photo.id }}.jpeg">
|
||||
</div>
|
||||
<br>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-lg-6 mt-3" style="text-align: center;">
|
||||
<i>No photos</i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user