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 0000000..92a72d6 Binary files /dev/null and b/titles/chuni/img/jacket/unknown.png differ 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 %} + +
JUSTICE CRITIAL | +JUSTICE CRITICAL | {{ record.raw.judgeCritical + record.raw.judgeHeaven }} | 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