├── .github └── workflows │ └── ci-cd.yml ├── .gitignore ├── .idea └── vcs.xml ├── LICENSE ├── README.md ├── backend ├── config.py ├── config.toml ├── main.py └── prprober │ ├── crud │ ├── __init__.py │ ├── record.py │ ├── song.py │ └── user.py │ ├── model │ ├── __init__.py │ ├── database.py │ ├── entities.py │ └── schemas.py │ ├── router │ ├── __init__.py │ ├── record.py │ ├── song.py │ ├── upload.py │ └── user.py │ ├── service │ ├── __init__.py │ ├── record.py │ ├── song.py │ └── user.py │ └── util │ ├── __init__.py │ ├── b50 │ ├── __init__.py │ ├── csv.py │ └── img.py │ ├── cache.py │ ├── database.py │ ├── ocr.py │ ├── rating.py │ └── security.py ├── docs ├── README_en.md └── deployment.md ├── requirements.txt ├── resources ├── formatted.json └── sql.db ├── temp └── upload │ └── b50csv │ └── maxscore.csv ├── tools ├── create_levels.py ├── import_wiki_id.py ├── update_levels.py └── update_rating.py └── unit_test ├── b50image ├── b50_test.py └── data.json ├── csv ├── gen_empty.py └── read_csv.py ├── ocr ├── ocr_test.py └── test_img │ ├── CO5M1C_R4ILROAD.PNG │ ├── REDRAVE.PNG │ ├── 今天不是明天.png │ ├── 帝王.jpg │ ├── 樱花怨雷.PNG │ └── 葬送歌.PNG └── rating └── rating_sum_test.py /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline for FastAPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | 15 | - name: SSH and Deploy 16 | uses: appleboy/ssh-action@master 17 | with: 18 | host: ${{ secrets.SERVER_IP }} 19 | username: ${{ secrets.SERVER_USER }} 20 | key: ${{ secrets.SSH_PRIVATE_KEY }} 21 | script: | 22 | cd /home/ubuntu/paradigm-reboot-prober-backend 23 | git reset --hard 24 | git clean -fd 25 | git fetch 26 | git pull 27 | source venv/bin/activate 28 | pip install -r requirements.txt 29 | sudo systemctl daemon-reload 30 | sudo systemctl restart uvicorn.service 31 | sudo systemctl status uvicorn.service 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | ======= 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | resources/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | sql.db 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Paradigm: Reboot Prober Development Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 范式:起源 查分器 2 | 3 | **中文** | [English](https://github.com/PRProber/paradigm-reboot-prober-backend/blob/master/docs/README_en.md) 4 | 5 | [前端](https://github.com/PRProber/paradigm-reboot-prober-frontend) 6 | 7 | ## 声明 8 | 9 | 本查分器仅为《范式:起源》 Rating 计算提供参考,不保证数据与计算 100% 正确,请以游戏内显示为准。 本软件与击弦网络及相关游戏发行、开发及分发公司无任何关系,均使用互联网公开资源,仅供学习研究用途,相关版权归相关方所有。 10 | 11 | **本查分器不包含任何直接访问官方服务器用户数据的功能,请勿使用本代码用于网络攻击或其他滥用行为。** 12 | 13 | ## 用户指南 14 | 15 | 前端目前已经部署,可以[直接访问](https://prp.icel.site)注册使用。 16 | 17 | ### 手动导入 / 导出 18 | 19 | #### 下载 Best 50 图 20 | 21 | ### 其他导入方式 22 | 23 | 请等待第三方开发者进行开发。 24 | 25 | ## 开发者指南 26 | 27 | ### API Docs 28 | 29 | 请访问 [FastAPI SwaggerUI](https://api.prp.icel.site/docs) 进行 API 的文档的查阅。 30 | 31 | ### 本地部署 32 | 33 | 请参考 [部署指南](https://github.com/PRProber/paradigm-reboot-prober-backend/blob/master/docs/deployment.md)。 34 | 35 | ### 项目结构 36 | 37 | 源代码均在 `backend` 软件包下。 38 | 39 | ``` 40 | │ config.py <-- API 相关配置 41 | │ main.py <-- Uvicorn 入口 42 | │ 43 | ├─crud <-- DAO 层实现 44 | │ │ record.py 45 | │ │ song.py 46 | │ │ user.py 47 | │ 48 | ├─model <-- 实体/schema 定义 49 | │ │ database.py 50 | │ │ entities.py 51 | │ │ schemas.py 52 | │ 53 | ├─router <-- Controller 层实现 54 | │ │ record.py 55 | │ │ song.py 56 | │ │ upload.py 57 | │ │ user.py 58 | │ 59 | ├─service <-- Service 层实现 60 | │ │ record.py 61 | │ │ song.py 62 | │ │ user.py 63 | │ 64 | ├─util 65 | │ cache.py <-- 序列化 Response 的 encoder / decoder 66 | │ database.py 67 | │ ocr.py 68 | │ rating.py <-- 提供 rating 的计算 69 | │ security.py <-- 提供 authorization / authentication 的工具类 70 | │ 71 | ├─b50 72 | │ csv.py <-- 提供 csv -> schemas.PlayRecordCreate 的转换 73 | │ img.py <-- 提供 best 50 图像的生成 74 | ``` 75 | 76 | ## 数据来源及计算方式参考 77 | 78 | 1. [@临履](https://space.bilibili.com/405967183), 范式:起源查分表(持续更新)及Rating计算上的细节讨论 [cv29479925](https://www.bilibili.com/read/cv29479925/) 79 | 2. [@Errno](https://space.bilibili.com/272105666), 关于范式:起源Rating计算方法,已知和未知的各种信息与推测 [cv28420633](https://www.bilibili.com/read/cv28420633/) 80 | 3. [@クロネコ](https://space.bilibili.com/390198606), 范式:起源 (Paradigm: Reboot) 定数表 81 | 4. [Paradigm: Reboot Wiki*](https://wikiwiki.jp/paradigm_/), 歌曲详情及曲绘 82 | 5. [Fandom Paradigm: Reboot Wiki](https://paradigmreboot.fandom.com/wiki/Paradigm:_Reboot_Wiki), 歌曲详情及 Logo 等资源 83 | -------------------------------------------------------------------------------- /backend/config.py: -------------------------------------------------------------------------------- 1 | # TODO: Use hierarchical toml file to store configs 2 | from datetime import timedelta 3 | import os 4 | 5 | DATABASE_URL = os.environ.get('PRP_DATABASE_URL') 6 | 7 | # Security Settings 8 | SECRETE_KEY = os.environ.get('PRP_SECRETE_KEY') 9 | JWT_ENCODE_ALGORITHM = 'HS256' 10 | TOKEN_EXPIRE_MINUTES = timedelta(minutes=1800) 11 | 12 | UPLOAD_CSV_PATH = 'temp/upload/b50csv/' 13 | UPLOAD_COVER_PATH = 'resources/image/cover/' 14 | RESOURCE_COVER_PATH = 'resources/image/cover/' 15 | RESOURCE_COVER_STATIC_PATH = 'resources/static/cover/' 16 | 17 | CHARACTERS = { 18 | "Para_Summer": 'resources/image/character/para_summer.png', 19 | "Para_Young_Awaken": 'resources/image/character/para_young_awaken.png', 20 | "Yun_Summer": 'resources/image/character/yun_summer.png', 21 | "Eden": 'resources/image/character/eden.png', 22 | "Geopelia": 'resources/image/character/geopelia.png' 23 | } 24 | -------------------------------------------------------------------------------- /backend/config.toml: -------------------------------------------------------------------------------- 1 | [database] 2 | url = 'sqlite:///./sql.db' -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi_cache import FastAPICache 4 | from fastapi_cache.backends.inmemory import InMemoryBackend 5 | 6 | from slowapi import _rate_limit_exceeded_handler 7 | from slowapi.errors import RateLimitExceeded 8 | 9 | from backend.prprober.router import record 10 | from backend.prprober.router import user, upload, song 11 | 12 | # from backend.prprober.util import database 13 | # database.init_db() 14 | 15 | app = FastAPI(root_path="/api/v1") 16 | app.state.limiter = user.limiter 17 | app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 18 | 19 | app.add_middleware( 20 | CORSMiddleware, 21 | allow_origins=["*"], 22 | allow_credentials=True, 23 | allow_methods=["*"], 24 | allow_headers=["*"], 25 | ) 26 | 27 | app.include_router(song.router) 28 | app.include_router(user.router) 29 | app.include_router(record.router) 30 | app.include_router(upload.router) 31 | 32 | 33 | @app.on_event("startup") 34 | async def startup(): 35 | FastAPICache.init(InMemoryBackend(), prefix="fastapi-cache") 36 | -------------------------------------------------------------------------------- /backend/prprober/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/backend/prprober/crud/__init__.py -------------------------------------------------------------------------------- /backend/prprober/crud/record.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Type 3 | 4 | from dateutil.relativedelta import relativedelta 5 | from sqlalchemy import select, func, and_, not_ 6 | from sqlalchemy.orm import Session 7 | 8 | from ..model.schemas import PlayRecordCreate 9 | from ..model.entities import PlayRecord, SongLevel, BestPlayRecord, Song, Best50Trends 10 | from ..util import rating 11 | 12 | 13 | def create_record(db: Session, record: PlayRecordCreate, username: str, is_replaced: bool = False) -> PlayRecord: 14 | """Record 15 | Create a play record. 16 | :param username: 17 | :param db: SQLAlchemy.orm Session 18 | :param record: record details 19 | :param is_replaced: whether to replace the best record or not 20 | :return: record entity 21 | """ 22 | 23 | db_song_level: SongLevel | None \ 24 | = (db.query(SongLevel).filter(SongLevel.song_level_id == record.song_level_id).first()) 25 | if db_song_level is None: 26 | raise RuntimeError("Song level does not exist") 27 | 28 | db_record = PlayRecord( 29 | song_level_id=record.song_level_id, 30 | record_time=datetime.now(), 31 | username=username, 32 | score=record.score, 33 | rating=rating.single_rating(db_song_level.level, record.score), 34 | ) 35 | 36 | db.add(db_record) 37 | db.commit() 38 | db.refresh(db_record) 39 | 40 | db_best_record: BestPlayRecord | None \ 41 | = (db.query(BestPlayRecord). 42 | join(PlayRecord). 43 | filter(PlayRecord.song_level_id == record.song_level_id). 44 | filter(PlayRecord.username == username).one_or_none()) 45 | if db_best_record: 46 | best_record: PlayRecord = db_best_record.play_record 47 | if is_replaced or db_record.score > best_record.score: 48 | setattr(db_best_record, "play_record", db_record) 49 | setattr(db_best_record, "play_record_id", db_record.play_record_id) 50 | db.commit() 51 | db.refresh(db_best_record) 52 | else: 53 | best_play_record: BestPlayRecord = BestPlayRecord( 54 | play_record_id=db_record.play_record_id, 55 | play_record=db_record, 56 | ) 57 | db.add(best_play_record) 58 | db.commit() 59 | db.refresh(best_play_record) 60 | 61 | return db_record 62 | 63 | 64 | def get_best50_records(db: Session, username: str, underflow: int = 0): 65 | """ 66 | Get best play records of a user. Returns a tuple. The first element is the list of records of old version (b35), 67 | and the second element is the list of records of new version (b15). 68 | :param db: SQLAlchemy.orm Session 69 | :param username: the username of the user 70 | :param underflow: underflow records threshold 71 | :return: (list, list) like tuple 72 | """ 73 | statement = \ 74 | (select(BestPlayRecord, PlayRecord). 75 | join(BestPlayRecord.play_record). 76 | join(PlayRecord.song_level). 77 | join(SongLevel.song). 78 | filter(PlayRecord.username == username)) 79 | 80 | b35_statement = statement.filter(not_(Song.b15)).order_by(PlayRecord.rating.desc()).limit(35 + underflow) 81 | b35 = db.execute(b35_statement).all() 82 | b15_statement = statement.filter(Song.b15).order_by(PlayRecord.rating.desc()).limit(15 + underflow) 83 | b15 = db.execute(b15_statement).all() 84 | 85 | return b35, b15 86 | 87 | 88 | def get_statement(statement_base, page_size: int, page_index: int, sort_by: (str, int), order: bool): 89 | (key, belong) = sort_by 90 | if belong == 1: 91 | statement = \ 92 | (statement_base. 93 | order_by(getattr(PlayRecord, key).desc() if order else getattr(PlayRecord, key).asc()). 94 | offset(page_size * (page_index - 1)). 95 | limit(page_size)) 96 | elif belong == 2: 97 | statement = \ 98 | (statement_base. 99 | join(PlayRecord.song_level). 100 | order_by(getattr(SongLevel, key).desc() if order else getattr(SongLevel, key).asc()). 101 | offset(page_size * (page_index - 1)). 102 | limit(page_size)) 103 | elif belong == 3: 104 | statement = \ 105 | (statement_base. 106 | join(PlayRecord.song_level). 107 | join(SongLevel.song). 108 | order_by(getattr(Song, key).desc() if order else getattr(Song, key).asc()). 109 | offset(page_size * (page_index - 1)). 110 | limit(page_size)) 111 | else: 112 | raise Exception('Unexpected belong') 113 | return statement 114 | 115 | 116 | def get_all_records(db: Session, username: str, page_size: int, page_index: int, sort_by: (str, int), order: bool): 117 | statement_base = \ 118 | (select(PlayRecord). 119 | filter(PlayRecord.username == username)) 120 | statement = get_statement(statement_base, page_size, page_index, sort_by, order) 121 | records = db.execute(statement).all() 122 | return records 123 | 124 | 125 | def get_best_records(db: Session, username: str, page_size: int, page_index: int, sort_by: (str, int), order: bool): 126 | statement_base = \ 127 | (select(BestPlayRecord, PlayRecord). 128 | join(BestPlayRecord.play_record). 129 | filter(PlayRecord.username == username)) 130 | statement = get_statement(statement_base, page_size, page_index, sort_by, order) 131 | records = db.execute(statement).all() 132 | return records 133 | 134 | 135 | def remove_b50_record(db: Session, record: Best50Trends): 136 | pass 137 | 138 | 139 | def update_b50_record(db: Session, username: str) -> Best50Trends: 140 | b35, b15 = get_best50_records(db, username) 141 | 142 | b50rating: float = 0 143 | for record in b35: 144 | b50rating += record[1].rating 145 | for record in b15: 146 | b50rating += record[1].rating 147 | b50rating /= 5000 148 | 149 | db_b50_record: Best50Trends = Best50Trends( 150 | username=username, 151 | b50rating=b50rating, 152 | record_time=datetime.now(), 153 | is_valid=True, 154 | ) 155 | 156 | db.add(db_b50_record) 157 | db.commit() 158 | db.refresh(db_b50_record) 159 | 160 | return db_b50_record 161 | 162 | 163 | def get_b50_trends(db: Session, username: str, scope: str | None = "month") -> List[Type[Best50Trends]]: 164 | current_time: datetime = datetime.now() 165 | limit_time: datetime = current_time 166 | if scope == "month": 167 | limit_time = current_time - relativedelta(months=1) 168 | elif scope == "season": 169 | limit_time = current_time - relativedelta(months=3) 170 | elif scope == "year": 171 | limit_time = current_time - relativedelta(years=1) 172 | 173 | trends: List[Type[Best50Trends]] = \ 174 | (db.query(Best50Trends). 175 | filter(Best50Trends.username == username, 176 | Best50Trends.is_valid, 177 | Best50Trends.record_time >= limit_time). 178 | order_by(Best50Trends.record_time).all()) 179 | return trends 180 | 181 | 182 | def count_best_records(db: Session, username: str) -> int: 183 | count = int(db.query(func.count(BestPlayRecord.best_record_id)) 184 | .join(PlayRecord).filter(PlayRecord.username == username).one()[0]) 185 | return count 186 | 187 | 188 | def count_all_records(db: Session, username: str) -> int: 189 | count = int(db.query(func.count(PlayRecord.play_record_id)).filter(PlayRecord.username == username).one()[0]) 190 | return count 191 | 192 | 193 | def get_all_levels_with_best_scores(db: Session, username: str): 194 | statement = \ 195 | (select(SongLevel, PlayRecord.score, BestPlayRecord.best_record_id). 196 | outerjoin(PlayRecord, 197 | and_(PlayRecord.song_level_id == SongLevel.song_level_id, PlayRecord.username == username)). 198 | outerjoin(BestPlayRecord, BestPlayRecord.play_record_id == PlayRecord.play_record_id). 199 | order_by(SongLevel.level.desc())) 200 | records = db.execute(statement).all() 201 | return records 202 | -------------------------------------------------------------------------------- /backend/prprober/crud/song.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from ..model import schemas 3 | from ..model.entities import Song, SongLevel 4 | 5 | 6 | REPLACEABLE_ATTRIBUTES = [ 7 | 'title', 'artist', 'cover', 'illustrator', 'bpm', 'b15', 'album', 'wiki_id' 8 | ] 9 | 10 | 11 | def get_all_songs(db: Session): 12 | return db.query(Song).all() 13 | 14 | 15 | def get_single_song_by_id(db: Session, song_id: int): 16 | return db.query(Song).filter(Song.song_id == song_id).one_or_none() 17 | 18 | 19 | def get_single_song_by_wiki_id(db: Session, song_id: str): 20 | return db.query(Song).filter(Song.wiki_id == song_id).one_or_none() 21 | 22 | 23 | def create_song(db: Session, song: schemas.SongCreate): 24 | db_song = Song( 25 | title=song.title, 26 | artist=song.artist, 27 | genre=song.genre, 28 | cover=song.cover, 29 | illustrator=song.illustrator, 30 | version=song.version, 31 | b15=song.b15, 32 | album=song.album, 33 | bpm=song.bpm, 34 | length=song.length, 35 | wiki_id=song.wiki_id 36 | ) 37 | db.add(db_song) 38 | db.commit() 39 | # 获得新的 song_id 40 | db.refresh(db_song) 41 | 42 | db_levels = [ 43 | SongLevel( 44 | song_id=db_song.song_id, 45 | difficulty_id=level.difficulty_id, 46 | level=level.level, 47 | level_design=level.level_design, 48 | notes=level.notes, 49 | ) 50 | for level in song.song_levels 51 | ] 52 | db.add_all(db_levels) 53 | db.commit() 54 | db.refresh(db_song) 55 | 56 | return db_song 57 | 58 | 59 | def update_song(db: Session, song: schemas.SongUpdate): 60 | db_song: Song | None \ 61 | = db.query(Song).filter(Song.song_id == song.song_id).first() 62 | if db_song is not None: 63 | # 更新可以直接替换的歌曲属性 64 | for attr in REPLACEABLE_ATTRIBUTES: 65 | if hasattr(song, attr) and getattr(song, attr) is not None: 66 | setattr(db_song, attr, getattr(song, attr)) 67 | # 更新定数,用难度进行匹配,没有的就当作新的难度添加 68 | if song.song_levels is not None: 69 | song_levels = {song_level.difficulty_id: song_level for song_level in song.song_levels} 70 | db_song_levels = {song_level.difficulty_id: song_level for song_level in db_song.song_levels} 71 | for diff_id in song_levels.keys(): 72 | if diff_id in db_song_levels.keys(): 73 | db_song_levels[diff_id].level = song_levels[diff_id].level 74 | else: 75 | new_level = SongLevel( 76 | song_id=db_song.song_id, 77 | difficulty_id=diff_id, 78 | level=song_levels[diff_id].level, 79 | level_design=song_levels[diff_id].level_design 80 | ) 81 | db.add(new_level) 82 | 83 | db.commit() 84 | db.refresh(db_song) 85 | 86 | return db_song 87 | -------------------------------------------------------------------------------- /backend/prprober/crud/user.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from ..model import schemas 6 | from ..model.entities import User 7 | from ..model.schemas import UserUpdate 8 | from ..util import security 9 | 10 | 11 | def get_user(db: Session, username: str) -> User | None: 12 | db_user = db.query(User).filter(User.username == username).first() 13 | return db_user 14 | 15 | 16 | def create_user(db: Session, user: schemas.UserCreate) -> User | None: 17 | db_user = db.query(User).filter(User.username == user.username).first() 18 | if db_user: 19 | return None 20 | 21 | db_user = User( 22 | username=user.username, 23 | nickname=user.username if user.nickname is None else user.nickname, 24 | encoded_password=security.encode_password(user.password), 25 | email=user.email, 26 | qq_number=user.qq_number, 27 | account=user.account, 28 | account_number=user.account_number, 29 | uuid=user.uuid, 30 | anonymous_probe=user.anonymous_probe, 31 | upload_token=secrets.token_hex(32), 32 | is_active=True, 33 | is_admin=False 34 | ) 35 | db.add(db_user) 36 | db.commit() 37 | db.refresh(db_user) 38 | 39 | return db_user 40 | 41 | 42 | def update_user(db: Session, username: str, update_info: UserUpdate): 43 | db_user: User | None = db.query(User).filter(User.username == username).one_or_none() 44 | for attr in UserUpdate.model_fields.keys(): 45 | if getattr(update_info, attr) is not None: 46 | setattr(db_user, attr, getattr(update_info, attr)) 47 | db.commit() 48 | db.refresh(db_user) 49 | return db_user 50 | -------------------------------------------------------------------------------- /backend/prprober/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/backend/prprober/model/__init__.py -------------------------------------------------------------------------------- /backend/prprober/model/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import sessionmaker 4 | 5 | from backend import config 6 | 7 | DATABASE_URL = config.DATABASE_URL 8 | engine = create_engine(DATABASE_URL) 9 | 10 | # 数据库会话 11 | SessionLocal = sessionmaker(engine, future=True) 12 | # ORM Base 13 | Base = declarative_base() 14 | -------------------------------------------------------------------------------- /backend/prprober/model/entities.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from datetime import datetime 3 | 4 | from sqlalchemy import ForeignKey 5 | from sqlalchemy.orm import Mapped 6 | from sqlalchemy.orm import mapped_column, relationship 7 | 8 | from .database import Base 9 | 10 | 11 | # 歌曲数据部分 12 | class Song(Base): 13 | __tablename__ = 'songs' 14 | 15 | song_id: Mapped[int] = mapped_column(primary_key=True) 16 | wiki_id: Mapped[str] = mapped_column(unique=True) 17 | title: Mapped[str] = mapped_column() 18 | artist: Mapped[str] = mapped_column() 19 | genre: Mapped[str] = mapped_column() 20 | cover: Mapped[str] = mapped_column() # 实际上为封面的*文件名* 21 | illustrator: Mapped[str] = mapped_column() 22 | version: Mapped[str] = mapped_column() 23 | b15: Mapped[bool] = mapped_column() # 是否为 b15 歌曲 24 | album: Mapped[str] = mapped_column() 25 | bpm: Mapped[str] = mapped_column() 26 | length: Mapped[str] = mapped_column() 27 | 28 | song_levels: Mapped[List["SongLevel"]] = relationship(back_populates='song') 29 | 30 | 31 | class Difficulty(Base): 32 | __tablename__ = 'difficulties' 33 | 34 | difficulty_id: Mapped[int] = mapped_column(primary_key=True) 35 | name: Mapped[str] = mapped_column() 36 | 37 | song_levels: Mapped[List["SongLevel"]] = relationship(back_populates='difficulty') 38 | 39 | 40 | class SongLevel(Base): 41 | __tablename__ = 'song_levels' 42 | 43 | song_level_id: Mapped[int] = mapped_column(primary_key=True) 44 | song_id: Mapped[int] = mapped_column(ForeignKey('songs.song_id')) 45 | difficulty_id: Mapped[int] = mapped_column(ForeignKey('difficulties.difficulty_id')) 46 | level: Mapped[float] = mapped_column() 47 | fitting_level: Mapped[Optional[float]] = mapped_column(nullable=True) 48 | level_design: Mapped[str] = mapped_column(nullable=True) # 考虑到合作作谱,设计上不把谱师独立建表 49 | notes: Mapped[int] = mapped_column() 50 | 51 | song: Mapped["Song"] = relationship(back_populates='song_levels') 52 | difficulty: Mapped["Difficulty"] = relationship(back_populates='song_levels') 53 | 54 | 55 | # 用户数据部分 56 | class User(Base): 57 | __tablename__ = 'prober_users' 58 | 59 | user_id: Mapped[int] = mapped_column(primary_key=True) 60 | username: Mapped[str] = mapped_column(unique=True) 61 | nickname: Mapped[str] = mapped_column() 62 | encoded_password: Mapped[str] = mapped_column() 63 | email: Mapped[str] = mapped_column() 64 | qq_number: Mapped[Optional[int]] = mapped_column() 65 | account: Mapped[Optional[str]] = mapped_column() 66 | account_number: Mapped[Optional[int]] = mapped_column() 67 | uuid: Mapped[Optional[str]] = mapped_column() 68 | anonymous_probe: Mapped[bool] = mapped_column() # 允许匿名查询成绩 69 | upload_token: Mapped[str] = mapped_column() # 匿名上传成绩 token 70 | is_active: Mapped[bool] = mapped_column() 71 | is_admin: Mapped[bool] = mapped_column() # 权限管理 72 | 73 | play_records: Mapped[List["PlayRecord"]] = relationship(back_populates='user') 74 | 75 | 76 | class PlayRecord(Base): 77 | __tablename__ = 'play_records' 78 | 79 | play_record_id: Mapped[int] = mapped_column(primary_key=True) 80 | song_level_id: Mapped[int] = mapped_column(ForeignKey('song_levels.song_level_id')) 81 | record_time: Mapped[datetime] = mapped_column() 82 | username: Mapped[str] = mapped_column(ForeignKey('prober_users.username')) 83 | score: Mapped[int] = mapped_column() 84 | rating: Mapped[float] = mapped_column() # 便于查询 b50 85 | 86 | user: Mapped["User"] = relationship(back_populates='play_records') 87 | song_level: Mapped["SongLevel"] = relationship() 88 | 89 | 90 | class BestPlayRecord(Base): 91 | __tablename__ = 'best_play_records' 92 | 93 | best_record_id: Mapped[int] = mapped_column(primary_key=True) 94 | play_record_id: Mapped[int] = mapped_column(ForeignKey('play_records.play_record_id')) 95 | 96 | play_record: Mapped["PlayRecord"] = relationship(uselist=False) 97 | 98 | 99 | class Best50Trends(Base): 100 | __tablename__ = 'best50_trend' 101 | 102 | b50_trend_id: Mapped[int] = mapped_column(primary_key=True) 103 | username: Mapped[str] = mapped_column(ForeignKey("prober_users.username")) 104 | 105 | b50rating: Mapped[float] = mapped_column() 106 | record_time: Mapped[datetime] = mapped_column() 107 | is_valid: Mapped[bool] = mapped_column() 108 | 109 | -------------------------------------------------------------------------------- /backend/prprober/model/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, EmailStr 2 | from datetime import datetime 3 | 4 | 5 | class SongBase(BaseModel): 6 | title: str | None = None 7 | artist: str | None = None 8 | genre: str | None = None 9 | cover: str | None = None 10 | illustrator: str | None = None 11 | version: str | None = None 12 | b15: bool | None = False 13 | album: str | None = None 14 | bpm: str | None = None 15 | length: str | None = None 16 | wiki_id: str | None = None 17 | 18 | 19 | class UserBase(BaseModel): 20 | username: str 21 | email: str 22 | nickname: str | None = None 23 | qq_number: int | None = None 24 | account: str | None = None 25 | account_number: int | None = None 26 | uuid: str | None = None 27 | anonymous_probe: bool | None = False 28 | upload_token: str | None = None 29 | is_active: bool | None = True 30 | is_admin: bool | None = False 31 | 32 | 33 | class PlayRecordBase(BaseModel): 34 | song_level_id: int 35 | score: int 36 | 37 | 38 | class PlayRecord(PlayRecordBase): 39 | username: str 40 | play_record_id: int 41 | record_time: datetime 42 | rating: float 43 | 44 | class Config: 45 | from_attributes = True 46 | 47 | 48 | class User(UserBase): 49 | class Config: 50 | from_attributes = True 51 | 52 | 53 | class UserInDB(UserBase): 54 | encoded_password: str 55 | 56 | class Config: 57 | from_attributes = True 58 | 59 | 60 | class SongLevelInfo(SongBase): 61 | song_id: int 62 | song_level_id: int 63 | difficulty_id: int 64 | difficulty: str | None = None 65 | level: float 66 | fitting_level: float | None = None 67 | level_design: str | None = None 68 | notes: int | None = None 69 | 70 | class Config: 71 | from_attributes = True 72 | 73 | 74 | class LevelInfo(BaseModel): 75 | difficulty_id: int 76 | difficulty: str | None = None 77 | level: float 78 | level_design: str | None = None 79 | notes: int | None = None 80 | 81 | class Config: 82 | from_attributes = True 83 | 84 | 85 | class Song(SongBase): 86 | song_id: int 87 | song_levels: list[LevelInfo] 88 | 89 | class Config: 90 | from_attributes = True 91 | 92 | 93 | class SongCreate(SongBase): 94 | song_levels: list[LevelInfo] 95 | 96 | 97 | class SongUpdate(SongBase): 98 | song_id: int 99 | song_levels: list[LevelInfo] | None = None 100 | 101 | 102 | class UserCreate(UserBase): 103 | username: str = Field(pattern=r'^[A-Za-z][A-Za-z0-9_]{5,15}$') 104 | email: EmailStr 105 | # TODO: 适配 Pydantic 的 Rust-style regex 校验 106 | password: str 107 | 108 | 109 | class UserUpdate(BaseModel): 110 | nickname: str | None = None 111 | qq_number: int | None = None 112 | account: str | None = None 113 | account_number: int | None = None 114 | uuid: str | None = None 115 | anonymous_probe: bool | None = False 116 | 117 | 118 | class PlayRecordCreate(PlayRecordBase): 119 | pass 120 | 121 | 122 | class BatchPlayRecordCreate(BaseModel): 123 | upload_token: str | None = None 124 | csv_filename: str | None = None 125 | is_replace: bool | None = False 126 | play_records: list[PlayRecordCreate] | None = None 127 | 128 | 129 | class SongLevelInfoSimple(BaseModel): 130 | wiki_id: str | None = None 131 | title: str | None = None 132 | version: str | None = None 133 | b15: bool | None = False 134 | song_id: int 135 | song_level_id: int 136 | difficulty_id: int 137 | difficulty: str 138 | level: float 139 | cover: str | None = None 140 | fitting_level: float | None = None 141 | 142 | 143 | class PlayRecordInfo(BaseModel): 144 | play_record_id: int 145 | record_time: datetime 146 | score: int 147 | rating: float 148 | song_level: SongLevelInfoSimple 149 | 150 | 151 | class PlayRecordResponse(BaseModel): 152 | username: str 153 | total: int | None = None 154 | records: list[PlayRecordInfo] 155 | 156 | 157 | class Token(BaseModel): 158 | access_token: str 159 | token_type: str 160 | 161 | 162 | class UploadToken(BaseModel): 163 | upload_token: str 164 | 165 | 166 | class SongLevelCsv(BaseModel): 167 | song_level_id: int 168 | title: str 169 | version: str 170 | difficulty: str 171 | level: float 172 | score: int | None = None 173 | 174 | 175 | class UploadFileResponse(BaseModel): 176 | filename: str -------------------------------------------------------------------------------- /backend/prprober/router/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/backend/prprober/router/__init__.py -------------------------------------------------------------------------------- /backend/prprober/router/record.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import Depends, HTTPException, Response 4 | from fastapi_cache.decorator import cache 5 | from sqlalchemy.orm import Session 6 | from slowapi import Limiter 7 | from slowapi.util import get_remote_address 8 | 9 | from backend.prprober.model import schemas 10 | from backend.prprober.model.schemas import UserInDB 11 | from backend.prprober.router.user import router 12 | from backend.prprober.service import record as record_service 13 | from backend.prprober.service import user as user_service 14 | from backend.prprober.service.user import check_probe_authority 15 | from backend.prprober.util.b50.csv import generate_csv, get_records_from_csv 16 | from backend.prprober.util.b50.img import generate_b50_img, image_to_byte_array 17 | from backend.prprober.util.cache import PNGImageResponseCoder, best50image_key_builder 18 | from backend.prprober.util.database import get_db 19 | import logging 20 | 21 | limiter = Limiter(key_func=get_remote_address) 22 | logging.basicConfig(level=logging.INFO) 23 | logger = logging.getLogger(__name__) 24 | 25 | SORT_BY_RECORD = ['rating', 'score', 'record_time'] 26 | SORT_BY_LEVEL = ['level', 'fitting_level'] 27 | SORT_BY_SONG = ['song_id', 'title', 'version', 'bpm'] 28 | 29 | 30 | @router.get('/records/{username}', response_model=schemas.PlayRecordResponse) 31 | @cache(expire=30) 32 | async def get_play_records(username: str, 33 | scope: str = "b50", underflow: int = 0, 34 | page_size: int = 50, 35 | page_index: int = 1, 36 | sort_by: str = "rating", 37 | order: str = "desc", 38 | current_user: UserInDB = Depends(user_service.get_current_user_or_none), 39 | db: Session = Depends(get_db)): 40 | await check_probe_authority(db, username, current_user) 41 | if sort_by not in SORT_BY_RECORD + SORT_BY_LEVEL + SORT_BY_SONG: 42 | raise HTTPException(status_code=400, detail='Invalid sort_by parameter') 43 | if order != "desc" and order != "asce": 44 | raise HTTPException(status_code=400, detail='Invalid order parameter') 45 | 46 | sort_type = 0 47 | if sort_by in SORT_BY_RECORD: 48 | sort_type = 1 49 | elif sort_by in SORT_BY_LEVEL: 50 | sort_type = 2 51 | elif sort_by in SORT_BY_SONG: 52 | sort_type = 3 53 | 54 | if scope == "b50": 55 | records = record_service.get_best50_records(db, username, underflow) 56 | elif scope == "best": 57 | records = record_service.get_best_records(db, username, page_size, page_index, (sort_by, sort_type), order) 58 | elif scope == "all": 59 | records = record_service.get_all_records(db, username, page_size, page_index, (sort_by, sort_type), order) 60 | else: 61 | raise HTTPException(status_code=400, detail='Invalid scope parameter') 62 | response = {"username": username, "records": records} 63 | if scope == "best": 64 | response["total"] = record_service.count_best_records(db, username) 65 | if scope == "all": 66 | response["total"] = record_service.count_all_records(db, username) 67 | return response 68 | 69 | 70 | @router.get('/records/{username}/export/b50') 71 | @limiter.limit('10/10minutes') 72 | @cache(expire=60, 73 | coder=PNGImageResponseCoder, 74 | key_builder=best50image_key_builder) 75 | async def get_b50_img(username: str, 76 | current_user: UserInDB = Depends(user_service.get_current_user), 77 | db: Session = Depends(get_db)): 78 | if current_user.username == username: 79 | records = record_service.get_best50_records(db, username) 80 | try: 81 | b50_img = await generate_b50_img(records, current_user.nickname) 82 | b50_img = image_to_byte_array(b50_img) 83 | return Response(content=b50_img, media_type="image/png") 84 | except Exception as e: 85 | logger.error(f"Error occurs while generating Best 50 image for user {username}: {e}", exc_info=True) 86 | raise HTTPException(status_code=500, 87 | detail=f"Error occurs while generating Best 50 image, please contact admin.") 88 | else: 89 | raise HTTPException(status_code=401, detail="Unauthorized") 90 | 91 | 92 | @router.get('/records/{username}/export/csv') 93 | def export_csv(username: str, 94 | current_user: UserInDB = Depends(user_service.get_current_user), 95 | db: Session = Depends(get_db)): 96 | if current_user.username == username: 97 | records = record_service.get_all_levels_with_best_scores(db, username) 98 | b50_csv = generate_csv(records).encode('utf-8-sig') 99 | return Response(content=b50_csv, media_type="text/csv") 100 | else: 101 | raise HTTPException(status_code=401, detail="Unauthorized") 102 | 103 | 104 | @router.post('/records/{username}', status_code=201, response_model=List[schemas.PlayRecord]) 105 | async def post_record(username: str, 106 | records: schemas.BatchPlayRecordCreate, 107 | current_user: UserInDB = Depends(user_service.get_current_user_or_none), 108 | db: Session = Depends(get_db)): 109 | if (records.play_records is None) == (records.csv_filename is None): 110 | raise HTTPException(status_code=400, detail='Ambiguous data') 111 | if records.play_records is not None: 112 | if current_user and current_user.username == username: 113 | response_msg = record_service.create_record(db, username, records.play_records, records.is_replace) 114 | else: 115 | user = await user_service.get_user(db, username) 116 | if user is None: 117 | raise HTTPException(status_code=401, detail="Cannot find user") 118 | if records.upload_token and records.upload_token == user.upload_token: 119 | response_msg = record_service.create_record(db, username, records.play_records, records.is_replace) 120 | else: 121 | raise HTTPException(status_code=401, detail="Unauthorized") 122 | else: 123 | records.play_records = get_records_from_csv(records.csv_filename) 124 | response_msg = record_service.create_record(db, username, records.play_records, is_replaced=True) 125 | if records and len(records.play_records) > 0: 126 | record_service.update_b50_record(db, username) 127 | return response_msg 128 | 129 | 130 | @router.get('/statistics/{username}/b50') 131 | @cache(expire=30) 132 | async def get_b50_trends(username: str, scope: str | None = 'month', 133 | current_user: UserInDB = Depends(user_service.get_current_user_or_none), 134 | db: Session = Depends(get_db)): 135 | await check_probe_authority(db, username, current_user) 136 | trends = record_service.get_b50_trends(db, username, scope) 137 | return trends 138 | -------------------------------------------------------------------------------- /backend/prprober/router/song.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException 2 | from fastapi_cache.decorator import cache 3 | from sqlalchemy.orm import Session 4 | 5 | from ..model import schemas, entities 6 | from ..util.database import get_db 7 | from ..service import song as song_service 8 | from ..service import user as user_service 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.get('/songs', response_model=list[schemas.SongLevelInfo]) 14 | @cache(expire=600) 15 | async def get_all_song_levels(db: Session = Depends(get_db)): 16 | song_levels = await song_service.get_all_song_levels(db) 17 | return sorted(song_levels, key=lambda x: x.song_level_id, reverse=True) 18 | 19 | 20 | @router.get('/songs/{song_id}', response_model=schemas.Song) 21 | @cache(expire=600) 22 | async def get_single_song_info(song_id: str, 23 | src: str = 'prp', 24 | db: Session = Depends(get_db)): 25 | if src not in ('prp', 'wiki'): 26 | raise HTTPException(status_code=404, detail="Source doesn't exist") 27 | song = await song_service.get_single_by_id(db, song_id, src) 28 | return song 29 | 30 | 31 | @router.post('/songs', response_model=list[schemas.SongLevelInfo]) 32 | async def create_song(song: schemas.SongCreate, db: Session = Depends(get_db), 33 | user: entities.User = Depends(user_service.get_current_user)): 34 | if user.is_admin: 35 | song_service.get_cover(song.cover) 36 | song_levels = song_service.create_song(db, song) 37 | return song_levels 38 | else: 39 | raise HTTPException(status_code=403, detail="You are not admin") 40 | 41 | 42 | @router.patch('/songs', response_model=list[schemas.SongLevelInfo]) 43 | async def update_song(song: schemas.SongUpdate, db: Session = Depends(get_db), 44 | user: entities.User = Depends(user_service.get_current_user)): 45 | if user.is_admin: 46 | song_service.get_cover(song.cover) 47 | song_levels = song_service.update_song(db, song) 48 | return song_levels 49 | else: 50 | raise HTTPException(status_code=403, detail="You are not admin") 51 | -------------------------------------------------------------------------------- /backend/prprober/router/upload.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from fastapi import UploadFile, Depends, HTTPException 4 | 5 | from backend import config 6 | from backend.prprober.model import schemas, entities 7 | from backend.prprober.router.user import router 8 | from backend.prprober.service import user as user_service 9 | 10 | 11 | @router.post('/upload/csv', response_model=schemas.UploadFileResponse) 12 | async def upload_csv(file: UploadFile, 13 | current_user: entities.User = Depends(user_service.get_current_user)): 14 | if current_user is None: 15 | raise HTTPException(status_code=401, detail='Unauthorized') 16 | if not file: 17 | raise HTTPException(status_code=400, detail='No file is provided') 18 | if file.content_type != 'text/csv': 19 | raise HTTPException(status_code=400, detail='Upload only CSV files') 20 | content = await file.read() 21 | filename = '_'.join([current_user.username, 'b50', str(secrets.token_hex(6))]) + '.csv' 22 | 23 | with open(config.UPLOAD_CSV_PATH + filename, 'wb') as f: 24 | f.write(content) 25 | f.close() 26 | 27 | return {'filename': filename} 28 | 29 | 30 | @router.post('/upload/img', response_model=schemas.UploadFileResponse) 31 | async def upload_img(file: UploadFile, 32 | current_user: entities.User = Depends(user_service.get_current_user)): 33 | if current_user.is_admin is False: 34 | raise HTTPException(status_code=401, detail='You are not admin') 35 | if not file: 36 | raise HTTPException(status_code=400, detail='No file is provided') 37 | if file.content_type not in ['image/jpg', 'image/jpeg', 'image/png']: 38 | raise HTTPException(status_code=400, detail='Upload only *.jpg, *.jpeg, *.png files') 39 | content = await file.read() 40 | filename = file.filename 41 | 42 | with open(config.UPLOAD_COVER_PATH + filename, 'wb') as f: 43 | f.write(content) 44 | f.close() 45 | 46 | return {'filename': filename} 47 | -------------------------------------------------------------------------------- /backend/prprober/router/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, Request 2 | from fastapi.security import OAuth2PasswordRequestForm 3 | from sqlalchemy.orm import Session 4 | 5 | from slowapi import Limiter 6 | from slowapi.util import get_remote_address 7 | 8 | from ..model import schemas 9 | from backend.prprober.model.schemas import UserInDB 10 | from backend.prprober.util.database import get_db 11 | from ..service import user as user_service 12 | 13 | router = APIRouter() 14 | limiter = Limiter(key_func=get_remote_address) 15 | 16 | 17 | @router.post('/user/register', response_model=schemas.User) 18 | @limiter.limit("10/10minutes") 19 | async def register(request: Request, 20 | user: schemas.UserCreate, 21 | db: Session = Depends(get_db)): 22 | user.username = user.username.lower() 23 | user = user_service.create_user(db, user) 24 | return user 25 | 26 | 27 | @router.post('/user/login', response_model=schemas.Token) 28 | @limiter.limit("10/minute") 29 | async def login(request: Request, 30 | form_data: OAuth2PasswordRequestForm = Depends(), 31 | db: Session = Depends(get_db)): 32 | form_data.username = form_data.username.lower() 33 | token = user_service.login(db, form_data.username, form_data.password) 34 | if token is None: 35 | raise HTTPException(status_code=400, detail="Incorrect username or password") 36 | return schemas.Token(access_token=token, token_type="bearer") 37 | 38 | 39 | @router.post('/user/me/upload-token', response_model=schemas.UploadToken) 40 | async def login(current_user: UserInDB = Depends(user_service.get_current_user), db: Session = Depends(get_db)): 41 | token = user_service.refresh_upload_token(db, current_user.username) 42 | return {'upload_token': token} 43 | 44 | 45 | @router.get('/user/me', response_model=schemas.User) 46 | async def get_my_info(user: UserInDB = Depends(user_service.get_current_user)): 47 | return user 48 | 49 | 50 | @router.patch('/user/me', response_model=schemas.User) 51 | async def update_user(update_info: schemas.UserUpdate, 52 | user: UserInDB = Depends(user_service.get_current_user_or_none), 53 | db: Session = Depends(get_db)): 54 | if user is None: 55 | raise HTTPException(status_code=401, detail="Unauthorized") 56 | user = user_service.update_user(db, user, update_info) 57 | return user 58 | -------------------------------------------------------------------------------- /backend/prprober/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/backend/prprober/service/__init__.py -------------------------------------------------------------------------------- /backend/prprober/service/record.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Type 2 | 3 | from fastapi import HTTPException 4 | from sqlalchemy import Row 5 | from sqlalchemy.exc import AmbiguousForeignKeysError 6 | from sqlalchemy.orm import Session 7 | 8 | from ..crud import record as crud 9 | from .. import util 10 | from ..model.entities import PlayRecord, BestPlayRecord, Best50Trends 11 | from ..model.schemas import PlayRecordCreate 12 | 13 | 14 | def create_record(db: Session, username: str, records: list[PlayRecordCreate], is_replaced: bool = False) \ 15 | -> List[PlayRecord]: 16 | response_records = [] 17 | try: 18 | for record in records: 19 | response_records.append(crud.create_record(db, record, username, is_replaced)) 20 | except RuntimeError as e: 21 | raise HTTPException(status_code=400, detail=e) 22 | except AmbiguousForeignKeysError: 23 | raise HTTPException(status_code=400, detail="User doesn't exist or song doesn't exist") 24 | except Exception as e: 25 | raise HTTPException(status_code=400, detail=e) 26 | return response_records 27 | 28 | 29 | def get_all_records(db: Session, username: str, page_size: int, page_index: int, sort_by: (str, int), order: str): 30 | unwrapped_records = crud.get_all_records(db, username, page_size, page_index, sort_by, order == "desc") 31 | records: List[util.PlayRecordInfo] = [] 32 | for record in unwrapped_records: 33 | records.append(util.PlayRecordInfo(record[0])) 34 | return records 35 | 36 | 37 | def wrap_record(record: Row[Tuple[BestPlayRecord, PlayRecord]]) -> util.PlayRecordInfo: 38 | best_record = util.PlayRecordInfo(record[1]) 39 | return best_record 40 | 41 | 42 | def get_best50_records(db: Session, username: str, underflow: int = 0): 43 | b35_records, b15_records = crud.get_best50_records(db, username, underflow) 44 | records: List[util.PlayRecordInfo] = [] 45 | for record in b35_records: 46 | records.append(wrap_record(record)) 47 | for record in b15_records: 48 | records.append(wrap_record(record)) 49 | return records 50 | 51 | 52 | def get_best_records(db: Session, username: str, page_size: int, page_index: int, sort_by: (str, int), order: str): 53 | unwrapped_records = crud.get_best_records(db, username, page_size, page_index, sort_by, order == "desc") 54 | records: List[util.PlayRecordInfo] = [] 55 | for record in unwrapped_records: 56 | records.append(wrap_record(record)) 57 | return records 58 | 59 | 60 | def get_all_levels_with_best_scores(db: Session, username: str): 61 | unwrapped_records = crud.get_all_levels_with_best_scores(db, username) 62 | records: List[util.SongLevelCsv] = [] 63 | for record in unwrapped_records: 64 | if record[1] is None or record[2] is not None: 65 | records.append(util.SongLevelCsv(record[0], record[1])) 66 | return records 67 | 68 | 69 | def count_best_records(db: Session, username: str) -> int: 70 | return crud.count_best_records(db, username) 71 | 72 | 73 | def count_all_records(db: Session, username: str) -> int: 74 | return crud.count_all_records(db, username) 75 | 76 | 77 | def remove_b50_record(db: Session, record: Best50Trends): 78 | # TODO: remove a b50 record 79 | pass 80 | 81 | 82 | def update_b50_record(db: Session, username: str) -> Best50Trends: 83 | trends = crud.update_b50_record(db, username) 84 | return trends 85 | 86 | 87 | def get_b50_trends(db: Session, username: str, scope: str | None) -> List[Type[Best50Trends]]: 88 | trends: List[Type[Best50Trends]] = crud.get_b50_trends(db, username, scope) 89 | return trends 90 | -------------------------------------------------------------------------------- /backend/prprober/service/song.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from typing import List 5 | 6 | from sqlalchemy.orm import Session 7 | from fastapi import HTTPException 8 | from fastapi_cache.decorator import cache 9 | 10 | from backend import config 11 | from ..model import schemas, entities 12 | from ..crud import song as crud 13 | from .. import util 14 | 15 | 16 | def song_to_levels(song: entities.Song) -> List[util.SongLevelInfo]: 17 | """ 18 | Convert a DAO layer song entity to doc-formatted song level objects. 19 | :param song: DAO layer song entity to be converted 20 | :return: A list of formatted song levels. 21 | """ 22 | song_levels = [] 23 | for level in song.song_levels: 24 | song_level = util.SongLevelInfo(level) 25 | song_level.difficulty = level.difficulty.name 26 | song_levels.append(song_level) 27 | 28 | return song_levels 29 | 30 | 31 | @cache(expire=60) 32 | async def get_all_song_levels(db: Session): 33 | songs: List[entities.Song] = crud.get_all_songs(db) 34 | song_levels: List[util.SongLevelInfo] = [] 35 | 36 | for song in songs: 37 | song_levels.extend(song_to_levels(song)) 38 | 39 | return song_levels 40 | 41 | 42 | @cache(expire=60) 43 | async def get_single_by_id(db: Session, song_id: str, src: str): 44 | if src == 'prp': 45 | song = crud.get_single_song_by_id(db, int(song_id)) 46 | elif src == 'wiki': 47 | song = crud.get_single_song_by_wiki_id(db, song_id) 48 | else: 49 | song = None 50 | if song is None: 51 | raise HTTPException(status_code=404, detail="Song doesn't exist") 52 | wrapped_song = util.SongInfo(song) 53 | # 展开 level.difficulty.name,不然 schema 没法序列化 54 | wrapped_song.song_levels = [ 55 | schemas.LevelInfo.model_validate({ 56 | key: getattr(level, key) if key != 'difficulty' else getattr(level, key).name 57 | for key in schemas.LevelInfo.model_fields.keys() 58 | }) for level in song.song_levels 59 | ] 60 | return wrapped_song 61 | 62 | 63 | def create_song(db: Session, song: schemas.SongCreate): 64 | return song_to_levels(crud.create_song(db, song)) 65 | 66 | 67 | def update_song(db: Session, song: schemas.SongUpdate): 68 | return song_to_levels(crud.update_song(db, song)) 69 | 70 | 71 | def get_cover(filename: str): 72 | if filename is not None and os.path.isfile(config.UPLOAD_COVER_PATH + filename): 73 | shutil.copyfile(config.UPLOAD_COVER_PATH + filename, config.RESOURCE_COVER_PATH + filename) 74 | shutil.copyfile(config.UPLOAD_COVER_PATH + filename, config.RESOURCE_COVER_STATIC_PATH + filename) 75 | -------------------------------------------------------------------------------- /backend/prprober/service/user.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Union, Type 3 | 4 | from fastapi import HTTPException, Depends 5 | from fastapi_cache.decorator import cache 6 | from sqlalchemy.orm import Session 7 | 8 | from backend import config 9 | from ..crud import user as crud 10 | from ..model.entities import User 11 | from ..model.schemas import UserInDB, UserCreate, UserUpdate 12 | from ..util import security, database 13 | from ..util.cache import UserInDBCoder 14 | 15 | 16 | def login(db: Session, username: str, plain_password: str) -> Union[str, None]: 17 | """ 18 | Generate access token. 19 | :param db: SQLAlchemy.Session 20 | :param username 21 | :param plain_password: 22 | :return: 23 | """ 24 | user: User = crud.get_user(db, username) 25 | if user: 26 | if not user.is_active: 27 | raise HTTPException(status_code=400, detail="Inactivated user") 28 | if security.verify_password(plain_password, user.encoded_password): 29 | return security.generate_access_jwt(username, config.TOKEN_EXPIRE_MINUTES) 30 | return None 31 | 32 | 33 | async def get_current_user_or_none(db: Session = Depends(database.get_db), 34 | token: str = Depends(security.optional_oauth2_scheme)) -> UserInDB | None: 35 | username = security.extract_username(token) 36 | if username is None: 37 | return None 38 | user = await get_user(db, username) 39 | return user if user and user.is_active else None 40 | 41 | 42 | async def get_current_user(db: Session = Depends(database.get_db), 43 | token: str = Depends(security.oauth2_scheme)) -> UserInDB: 44 | """ 45 | For view functions to get current authorized user. 46 | :param db: SQLAlchemy.Session 47 | :param token: FastAPI Depends 48 | :return: 49 | """ 50 | username = security.extract_username(token) 51 | return await get_active_user(db, username) 52 | 53 | 54 | @cache(expire=2, coder=UserInDBCoder) 55 | async def get_active_user(db: Session, username: str) -> Union[UserInDB, None]: 56 | user: User = crud.get_user(db, username) 57 | if user: 58 | if not user.is_active: 59 | raise HTTPException(status_code=400, detail="Inactivated user") 60 | else: 61 | raise HTTPException(status_code=400, detail="Incorrect username or password") 62 | return UserInDB.model_validate(user) if user else None 63 | 64 | 65 | @cache(expire=2, coder=UserInDBCoder) 66 | async def get_user(db: Session, username: str) -> Union[UserInDB, None]: 67 | user: User = crud.get_user(db, username) 68 | return UserInDB.model_validate(user) if user else None 69 | 70 | 71 | def create_user(db: Session, user: UserCreate) -> User: 72 | user: User | None = crud.create_user(db, user) 73 | if user is None: 74 | raise HTTPException(status_code=400, detail="Username has already existed") 75 | return user 76 | 77 | 78 | def refresh_upload_token(db: Session, username: str) -> str: 79 | user: Type[User] = db.query(User).filter(User.username == username).one() 80 | user.upload_token = secrets.token_hex(32) 81 | db.commit() 82 | db.refresh(user) 83 | 84 | return user.upload_token 85 | 86 | 87 | async def check_probe_authority(db: Session, username: str, current_user: UserInDB | None): 88 | user = await get_user(db, username) 89 | if user is None: 90 | raise HTTPException(status_code=404, detail="User not found") 91 | # !(允许匿名查询 | 认证 & 信息匹配 | 认证 & 是管理员) 92 | elif not (user.anonymous_probe or 93 | (current_user and current_user.username == username) or 94 | (current_user and current_user.is_admin)): 95 | if not user.anonymous_probe: 96 | raise HTTPException(status_code=403, detail="Anonymous probes are not allowed") 97 | if current_user and current_user.username != username: 98 | raise HTTPException(status_code=401, detail="Authentication info not matched") 99 | 100 | 101 | def update_user(db: Session, user: UserInDB, update_info: UserUpdate): 102 | return crud.update_user(db, user.username, update_info) 103 | -------------------------------------------------------------------------------- /backend/prprober/util/__init__.py: -------------------------------------------------------------------------------- 1 | from backend.prprober.model import entities 2 | from backend.prprober.model import schemas 3 | 4 | 5 | class SongLevelInfo: 6 | """ 7 | A simple wrapper class to convert a composed song with levels to independent song levels (chart). 8 | """ 9 | def __init__(self, level: entities.SongLevel): 10 | for key in schemas.SongLevelInfo.model_fields.keys(): 11 | if hasattr(level.song, key): 12 | setattr(self, key, getattr(level.song, key)) 13 | elif hasattr(level, key): 14 | setattr(self, key, getattr(level, key)) 15 | else: 16 | setattr(self, key, None) 17 | 18 | 19 | class PlayRecordInfo: 20 | """ 21 | A simple wrapper class to convert a composed song with levels to independent song levels (chart). 22 | """ 23 | def __init__(self, record: entities.PlayRecord): 24 | for key in schemas.PlayRecordInfo.model_fields.keys(): 25 | if hasattr(record, key): 26 | setattr(self, key, getattr(record, key)) 27 | else: 28 | setattr(self, key, None) 29 | song_level = SongLevelInfo(record.song_level) 30 | song_level.difficulty = song_level.difficulty.name 31 | setattr(self, 'song_level', song_level) 32 | 33 | 34 | class SongInfo: 35 | """ 36 | A simple wrapper class to convert an ORM song to a Song schema (flatten entities.Difficulty). 37 | """ 38 | def __init__(self, song: entities.Song): 39 | for key in schemas.SongLevelInfo.model_fields.keys(): 40 | if hasattr(song, key): 41 | setattr(self, key, getattr(song, key)) 42 | else: 43 | setattr(self, key, None) 44 | 45 | 46 | class SongLevelCsv: 47 | def __init__(self, level: entities.SongLevel, score: int | None): 48 | for key in schemas.SongLevelCsv.model_fields.keys(): 49 | if hasattr(level.song, key): 50 | setattr(self, key, getattr(level.song, key)) 51 | elif hasattr(level, key): 52 | setattr(self, key, getattr(level, key)) 53 | else: 54 | setattr(self, key, None) 55 | setattr(self, 'difficulty', level.difficulty.name) 56 | setattr(self, 'score', score) 57 | -------------------------------------------------------------------------------- /backend/prprober/util/b50/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/backend/prprober/util/b50/__init__.py -------------------------------------------------------------------------------- /backend/prprober/util/b50/csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import io 3 | from pathlib import Path 4 | from typing import List 5 | 6 | from pydantic import ValidationError 7 | 8 | from backend import config as backend_config 9 | from backend.prprober.model.schemas import PlayRecordCreate, SongLevelInfo, SongLevelCsv 10 | 11 | 12 | def generate_csv(records: List[SongLevelCsv]): 13 | csv_bytes = io.StringIO() 14 | writer = csv.DictWriter(csv_bytes, fieldnames=['song_level_id', 'title', 'version', 'difficulty', 'level', 'score']) 15 | writer.writeheader() 16 | for level in records: 17 | writer.writerow(vars(level)) 18 | return csv_bytes.getvalue() 19 | 20 | 21 | def get_records_from_csv(filename: str = "default.csv") -> List[PlayRecordCreate]: 22 | records: List[PlayRecordCreate] = [] 23 | with open(backend_config.UPLOAD_CSV_PATH + filename, 'r', encoding='utf-8-sig') as f: 24 | reader = csv.DictReader(f) 25 | for row in reader: 26 | try: 27 | record = PlayRecordCreate(**row) 28 | records.append(record) 29 | except ValidationError as e: 30 | pass 31 | return records 32 | 33 | 34 | def generate_empty_csv(file_path: Path, song_levels: List[SongLevelInfo]): 35 | with open(file_path / 'default.csv', 'w', encoding='utf-8-sig', newline="") as f: 36 | writer = csv.DictWriter(f, fieldnames=['song_level_id', 'title', 'version', 'difficulty', 'level', 'score']) 37 | writer.writeheader() 38 | for level in song_levels: 39 | writer.writerow(vars(SongLevelCsv(**level))) 40 | f.close() 41 | -------------------------------------------------------------------------------- /backend/prprober/util/b50/img.py: -------------------------------------------------------------------------------- 1 | # TODO: Implement b50 table generation functions 2 | import json 3 | import io 4 | import os 5 | 6 | from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageFilter, ImageEnhance 7 | 8 | from backend import config as backend_config 9 | from ...model.schemas import PlayRecordInfo 10 | 11 | 12 | def image_to_byte_array(image: Image) -> bytes: 13 | imgByteArr = io.BytesIO() 14 | image.save(imgByteArr, format="png") 15 | imgByteArr = imgByteArr.getvalue() 16 | return imgByteArr 17 | 18 | 19 | def draw_single_text(draw: ImageDraw, font: ImageFont, config, content): 20 | df = font.font_variant(size=config['font_size']) 21 | draw.multiline_text((config['x'], config['y']), 22 | text=content, 23 | font=df, 24 | fill=tuple(config['text_rgba'])) 25 | 26 | 27 | def draw_single_text_border(draw: ImageDraw, font: ImageFont, config, content, color=(0, 0, 0, 0)): 28 | df = font.font_variant(size=config['font_size']) 29 | draw.multiline_text((config['x'] + 1, config['y']), 30 | text=content, font=df, fill=color) 31 | draw.multiline_text((config['x'] - 1, config['y']), 32 | text=content, font=df, fill=color) 33 | draw.multiline_text((config['x'], config['y'] + 1), 34 | text=content, font=df, fill=color) 35 | draw.multiline_text((config['x'], config['y'] - 1), 36 | text=content, font=df, fill=color) 37 | 38 | 39 | async def generate_b50_img(play_records: list[PlayRecordInfo], nickname, character: str = 'Para_Young_Awaken', 40 | credential_info: str = "Generated by PRP-API", 41 | config_path: str = "resources/image/template/b50_ruins_template_thin.json", 42 | height: int = 1080): 43 | b35, b15 = [], [] 44 | b35_ra, b15_ra = 0, 0 45 | for record in play_records: 46 | if record.song_level.b15: 47 | b15_ra += record.rating 48 | b15.append(record) 49 | else: 50 | b35_ra += record.rating 51 | b35.append(record) 52 | 53 | b50_ra = (b35_ra + b15_ra) / 5000 54 | b35_ra /= 3500 55 | b15_ra /= 1500 56 | username = nickname 57 | 58 | # Generate Image process 59 | with open(config_path, 'r', encoding='utf-8') as f: 60 | config = json.load(f) 61 | template = Image.open(config['file']).convert("RGBA") 62 | font = ImageFont.truetype(config['font'], encoding='unic') 63 | title_font = ImageFont.truetype(config['title_font'], encoding='unic') 64 | score_font = ImageFont.truetype(config['score_font'], encoding='unic') 65 | draw = ImageDraw.Draw(template) 66 | 67 | # Draw credential 68 | draw_single_text(draw, font, config['generated_by'], credential_info) 69 | 70 | # Draw username 71 | draw_single_text(draw, font, config['username'], username) 72 | 73 | # Draw rating 74 | draw_single_text(draw, font, config['rating'], "%.4f" % b50_ra) 75 | draw_single_text(draw, font, config['b35_rating'], "%.4f" % b35_ra) 76 | draw_single_text(draw, font, config['b15_rating'], "%.4f" % b15_ra) 77 | 78 | # Draw character: 79 | character_img = Image.open(backend_config.CHARACTERS[character]).convert('RGBA') 80 | character_img = ImageOps.contain(character_img, (config['character']['width'], config['character']['height'])) 81 | template.alpha_composite(character_img, (config['character']['x'], config['character']['y'])) 82 | 83 | # Draw single 84 | x_offset, y_offset = config['b35_offset']['x'], config['b35_offset']['y'] 85 | x_padding, y_padding = config['padding']['x'], config['padding']['y'] 86 | for i, record in enumerate(b35): 87 | single = generate_single(config, font, title_font, score_font, record, i + 1) 88 | template.alpha_composite(single, (x_offset, y_offset)) 89 | x_offset += config['single']['width'] + x_padding 90 | if (i + 1) % 5 == 0: 91 | x_offset = config['b35_offset']['x'] 92 | y_offset += config['single']['height'] + y_padding 93 | 94 | x_offset, y_offset = config['b15_offset']['x'], config['b15_offset']['y'] 95 | for i, record in enumerate(b15): 96 | single = generate_single(config, font, title_font, score_font, record, i + 1) 97 | template.alpha_composite(single, (x_offset, y_offset)) 98 | x_offset += config['single']['width'] + x_padding 99 | if (i + 1) % 5 == 0: 100 | x_offset = config['b15_offset']['x'] 101 | y_offset += config['single']['height'] + y_padding 102 | 103 | template = template.resize((int(template.width / template.height * height), height)) 104 | return template 105 | 106 | 107 | def generate_single(config, font: ImageFont, title_font: ImageFont, score_font: ImageFont, 108 | record: PlayRecordInfo, index: int, radius=3): 109 | cover_path = backend_config.RESOURCE_COVER_PATH + record.song_level.cover 110 | if not os.path.exists(cover_path): 111 | cover_path = backend_config.RESOURCE_COVER_PATH + 'default.png' 112 | cover = Image.open(cover_path).convert("RGBA") 113 | cover = ImageOps.fit(cover, (config['single']['width'] - radius * 2, config['single']['height'] - radius * 2)) 114 | single = Image.new("RGBA", (config['single']['width'], config['single']['width']), (0, 0, 0, 0)) 115 | single.paste(cover, (radius, radius)) 116 | single = single.filter(ImageFilter.GaussianBlur(radius=radius)) 117 | enhancer = ImageEnhance.Brightness(single) 118 | single = enhancer.enhance(0.7) 119 | single_draw = ImageDraw.Draw(single) 120 | # Draw single title 121 | if len(record.song_level.title) > 18: 122 | record.song_level.title = record.song_level.title[:15] + '...' 123 | draw_single_text_border(single_draw, title_font, config['single']['title'], record.song_level.title) 124 | draw_single_text(single_draw, title_font, config['single']['title'], record.song_level.title) 125 | # Draw index 126 | index_str = '#' + ('0' if index < 10 else '') + str(index) 127 | draw_single_text_border(single_draw, font, config['single']['index'], index_str) 128 | draw_single_text(single_draw, font, config['single']['index'], index_str) 129 | # Draw single difficulty 130 | draw_single_text_border(single_draw, font, config['single']['difficulty'], 131 | record.song_level.difficulty) 132 | if record.song_level.difficulty == 'Detected': 133 | color = config['det_rgba'] 134 | elif record.song_level.difficulty == 'Invaded': 135 | color = config['ivd_rgba'] 136 | elif record.song_level.difficulty == 'Massive': 137 | color = config['msv_rgba'] 138 | else: 139 | color = config['text_rgba'] 140 | df = font.font_variant(size=config['single']['difficulty']['font_size']) 141 | single_draw.multiline_text((config['single']['difficulty']['x'], config['single']['difficulty']['y']), 142 | text=record.song_level.difficulty, 143 | fill=tuple(color), font=df) 144 | # Draw single score 145 | draw_single_text_border(single_draw, score_font, config['single']['score'], str(record.score)) 146 | draw_single_text(single_draw, score_font, config['single']['score'], str(record.score)) 147 | # Draw single rating 148 | draw_single_text_border(single_draw, font, config['single']['rating'], 149 | "%.1f->%.2f" % (record.song_level.level, record.rating / 100)) 150 | draw_single_text(single_draw, font, config['single']['rating'], 151 | "%.1f->%.2f" % (record.song_level.level, record.rating / 100)) 152 | # Paste to template 153 | return single 154 | 155 | 156 | -------------------------------------------------------------------------------- /backend/prprober/util/cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fastapi import Response, Request 4 | from fastapi_cache import Coder, JsonCoder 5 | 6 | from ..model.schemas import UserInDB 7 | 8 | 9 | def best50image_key_builder(func, 10 | namespace: str = "", 11 | *, 12 | request: Request = None, 13 | response: Response = None, 14 | **kwargs 15 | ): 16 | return ':'.join([namespace, request.method.lower(), request.url.path]) 17 | 18 | 19 | class PNGImageResponseCoder(Coder): 20 | @classmethod 21 | def encode(cls, value: Response) -> bytes: 22 | return value.body 23 | 24 | @classmethod 25 | def decode(cls, value: bytes) -> Response: 26 | return Response(content=value, media_type='image/png') 27 | 28 | 29 | class UserInDBCoder(JsonCoder): 30 | @classmethod 31 | def decode(cls, value: str) -> UserInDB: 32 | return UserInDB.model_validate(json.loads(value)) 33 | -------------------------------------------------------------------------------- /backend/prprober/util/database.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sqlalchemy.orm import Session 4 | from sqlalchemy.exc import AmbiguousForeignKeysError 5 | 6 | from ..model import database, entities 7 | from ..model.schemas import SongCreate 8 | from ..model.database import SessionLocal 9 | from ..crud.song import create_song 10 | 11 | 12 | def init_db(): 13 | database.Base.metadata.create_all(bind=database.engine) 14 | # 15 | # with Session(database.engine) as db: 16 | # init_difficulties(db) 17 | # init_songs(db) 18 | 19 | 20 | def init_difficulties(db: Session): 21 | diffs = ["Detected", "Invaded", "Massive"] 22 | db_diffs = [entities.Difficulty(name=diff) for diff in diffs] 23 | db.add_all(db_diffs) 24 | db.commit() 25 | 26 | 27 | def init_songs(db: Session): 28 | songs: list 29 | try: 30 | with open('resources/formatted.json', 'r', encoding='utf-8') as f: 31 | songs = json.load(f) 32 | except FileNotFoundError | json.JSONDecodeError as e: 33 | print("Error occurs when initializing song information\n", e) 34 | 35 | for song in songs: 36 | song_create = SongCreate.model_validate(song) 37 | try: 38 | create_song(db, song_create) 39 | except AmbiguousForeignKeysError as e: 40 | print("Ambiguous foreign key error occurs when initializing song information\n", e) 41 | 42 | 43 | def get_db(): 44 | db = SessionLocal() 45 | try: 46 | yield db 47 | finally: 48 | db.close() 49 | 50 | 51 | def get_db_sync(): 52 | return SessionLocal() 53 | -------------------------------------------------------------------------------- /backend/prprober/util/ocr.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | from PIL import Image 5 | from thefuzz import fuzz 6 | from paddleocr import PaddleOCR, draw_ocr 7 | 8 | 9 | logging.disable(logging.DEBUG) 10 | ch_ocr = PaddleOCR(use_angle_cls=True, lang="ch") 11 | jp_ocr = PaddleOCR(use_angle_cls=True, lang="japan") 12 | 13 | 14 | def _ocr_result_display(result, img_path: str, output_name): 15 | result = result[0] 16 | image = Image.open(img_path).convert('RGB') 17 | boxes = [line[0] for line in result] 18 | texts = [line[1][0] for line in result] 19 | scores = [line[1][1] for line in result] 20 | im_show = draw_ocr(image, boxes, texts, scores) 21 | im_show = Image.fromarray(im_show) 22 | im_show.save(output_name) 23 | 24 | 25 | def _concentrate_string(result, threshold=0.8): 26 | result_str = '' 27 | for idx in range(len(result)): 28 | res = result[idx] 29 | for line in res: 30 | text, score = line[1] 31 | if score > threshold: 32 | result_str += text + ' ' 33 | return result_str 34 | 35 | 36 | def extract_record_info(img_path: str, 37 | song_titles: list[str], 38 | difficulties: list[str], 39 | threshold: int = 75) -> dict: 40 | """ 41 | Given a screenshot of a record, list of available song titles and difficulties, extract the corresponding record 42 | information. The result dictionary is like ``{"title": (str, dist), "difficulty": (str, dist), "score": int}`` 43 | :param threshold: Levenshtein Distance threshold 44 | :param difficulties: list of difficulties 45 | :param song_titles: list of available song titles 46 | :param img_path: the path of screenshot 47 | :return: A dictionary contains record information. Keys: ``'title'``, ``'difficulty'``, ``'score'`` 48 | """ 49 | 50 | ch, jp = ch_ocr.ocr(img_path, cls=True), jp_ocr.ocr(img_path, cls=True) 51 | ch_str, jp_str = _concentrate_string(ch), _concentrate_string(jp) 52 | result = {"title": ('', threshold), "difficulty": ('', threshold), "score": int} 53 | 54 | for title in song_titles: 55 | _, title_score = result['title'] 56 | ch_score = fuzz.partial_token_sort_ratio(title, ch_str) 57 | jp_score = fuzz.partial_token_sort_ratio(title, jp_str) 58 | if max(ch_score, jp_score) > threshold and max(ch_score, jp_score) > title_score: 59 | result['title'] = (title, max(ch_score, jp_score)) 60 | 61 | for difficulty in difficulties: 62 | _, diff_score = result['difficulty'] 63 | score = fuzz.partial_token_sort_ratio(difficulty, ch_str) 64 | if score > threshold and score > diff_score: 65 | result['difficulty'] = (difficulty, score) 66 | 67 | # TODO: Strengthen robustness 68 | score = re.findall('[0-9]{7}', ch_str)[0] 69 | result['score'] = int(score) 70 | 71 | return result 72 | -------------------------------------------------------------------------------- /backend/prprober/util/rating.py: -------------------------------------------------------------------------------- 1 | bounds = [900000, 930000, 950000, 970000, 980000, 990000] 2 | rewards = [3, 1, 1, 1, 1, 1] 3 | 4 | EPS = 0.00002 5 | 6 | 7 | def single_rating(level: float, score: int) -> float: 8 | """ 9 | Calculate the rating of a single chart. 10 | :param level: the float level of the chart. e.g. 16.4 11 | :param score: the score of a play record. e.g. 1008900 12 | :return: the (avg) rating. 13 | """ 14 | # Reference: https://www.bilibili.com/read/cv29433852 15 | global bounds, rewards 16 | rating: float = 0 17 | 18 | score = min(score, 1010000) 19 | 20 | if score >= 1009000: 21 | rating = level * 10 + 7 + 3 * (((score - 1009000) / 1000) ** 1.35) 22 | elif score >= 1000000: 23 | rating = 10 * (level + 2 * (score - 1000000) / 30000) 24 | else: 25 | for bound, reward in zip(bounds, rewards): 26 | rating += reward if score >= bound else 0 27 | rating += 10 * (level * ((score / 1000000) ** 1.5) - 0.9) 28 | 29 | rating = max(.0, rating) 30 | 31 | int_rating: int = int(rating * 100 + EPS) 32 | return int_rating 33 | -------------------------------------------------------------------------------- /backend/prprober/util/security.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | from datetime import datetime, timedelta, timezone 3 | 4 | from fastapi import Depends, HTTPException, status 5 | from fastapi.security import OAuth2PasswordBearer 6 | from passlib.context import CryptContext 7 | from jose import JWTError, jwt 8 | 9 | from backend import config 10 | 11 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl='user/login') 12 | optional_oauth2_scheme = OAuth2PasswordBearer(tokenUrl='user/login', auto_error=False) 13 | pwd_context = CryptContext(schemes=['bcrypt']) 14 | 15 | bad_credential_exception = HTTPException( 16 | status_code=status.HTTP_401_UNAUTHORIZED, 17 | detail="Could not validate credentials", 18 | headers={"WWW-Authenticate": "Bearer"}, 19 | ) 20 | 21 | 22 | def verify_password(plain_password: str, encoded_password: str): 23 | return pwd_context.verify(plain_password, encoded_password) 24 | 25 | 26 | def encode_password(password: str | bytes): 27 | return pwd_context.hash(password) 28 | 29 | 30 | def generate_jwt(data: dict, expires_delta: Union[timedelta, None] = None): 31 | to_encode = data.copy() 32 | if expires_delta: 33 | expire = datetime.now(timezone.utc) + expires_delta 34 | else: 35 | expire = datetime.now(timezone.utc) + timedelta(minutes=30) 36 | to_encode.update({"exp": expire}) 37 | encoded_jwt = jwt.encode(to_encode, config.SECRETE_KEY, algorithm=config.JWT_ENCODE_ALGORITHM) 38 | return encoded_jwt 39 | 40 | 41 | def generate_access_jwt(username: str, expires_delta: Union[timedelta, None] = None): 42 | """ 43 | Invoked by service.login(). 44 | :param username: 45 | :param expires_delta: 46 | :return: 47 | """ 48 | return generate_jwt(data={"sub": username}, expires_delta=expires_delta) 49 | 50 | 51 | def extract_payloads(token: str): 52 | payload = jwt.decode(token, config.SECRETE_KEY, algorithms=[config.JWT_ENCODE_ALGORITHM]) 53 | return payload 54 | 55 | 56 | def extract_username(token: str) -> str | None: 57 | """ 58 | Extract username from a JWT. 59 | :param token: JWT. 60 | :return: username: extracted username. 61 | """ 62 | try: 63 | payload = extract_payloads(token) 64 | except Exception as e: 65 | return None 66 | return payload.get("sub") 67 | -------------------------------------------------------------------------------- /docs/README_en.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/docs/README_en.md -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | ## 后端部署使用 2 | 3 | Python版本 >= 3.10 !!! 4 | 5 | 这是一个`Fastapi`后端项目,使用的工具链: 6 | 7 | ### **1.FastAPI** 8 | 9 | - **作用**: FastAPI 是一个现代、快速的Web框架,专为构建API而设计,强调速度和易用性,同时自动提供数据验证和交互式文档。 10 | - **用途**: 在本项目中,FastAPI 用于构建和维护所有后端API服务,提供数据处理和业务逻辑的实现。 11 | 12 | ### 2. **Uvicorn** 13 | 14 | - **作用**: Uvicorn 是一个轻量级的 ASGI 兼容的 Web 服务器,它能够运行异步Python Web代码,提供出色的并发支持。 15 | - **用途**: 本项目使用 Uvicorn 作为 FastAPI 框架的 Web 服务器,负责处理入站的 HTTP 请求。 16 | 17 | ### 3. **Systemd** 18 | 19 | - **作用**: Systemd 是一个广泛使用的 Linux 初始化系统和服务管理器,它允许你配置和管理系统服务。 20 | - **用途**: 在本项目中,Systemd 用于管理 Uvicorn 服务的启动、停止、重启以及在系统重启后自动启动。 21 | 22 | ### 4. **Nginx** 23 | 24 | - **作用**: Nginx 是一个高性能的 Web 和反向代理服务器,它处理静态内容、负载均衡和HTTP缓存的效率非常高。 25 | - **用途**: 本项目中,Nginx 用作反向代理服务器,增加了缓存处理,负载均衡,并且处理来自 Internet 的 HTTPS 请求,提高安全性和性能。 26 | 27 | ### 5. **Certbot** 28 | 29 | - **作用**: Certbot 是一个自动化的工具,用于获取和更新 Let's Encrypt 提供的 SSL/TLS 证书,简化了 HTTPS 的实现过程。 30 | - **用途**: 本项目利用 Certbot 自动为 Nginx 配置的 HTTPS 服务获取和维护 SSL/TLS 证书,确保通信的加密和安全。 31 | 32 | ## 部署流程 33 | 34 | ### 步骤 1: 准备应用环境 35 | 36 | 1. **安装并设置 FastAPI 应用**: 确保你的 FastAPI 应用已经安装在 `/home/ubuntu/paradigm-reboot-prober-backend` 目录中,并且所有依赖都已通过 `pip` 安装在该目录下的虚拟环境中。 37 | 2. **虚拟环境**: 应用应该在一个 Python 虚拟环境中运行。这个虚拟环境通常位于应用目录内,例如 `/home/ubuntu/paradigm-reboot-prober-backend/venv`。 38 | 39 | 然后进入虚拟环境: 40 | 41 | ```apl 42 | source venv/bin/activate 43 | ``` 44 | 45 | 安装依赖: 46 | 47 | ```apl 48 | pip install -r requirements.txt 49 | ``` 50 | 51 | 手动启动项目: 52 | 53 | ```apl 54 | uvicorn backend.main:app --port 8000 --log-level debug 55 | ``` 56 | 57 | ### 步骤 2: 配置 systemd 服务文件 58 | 59 | 1. **基本配置**: 60 | - `Description`:简要描述服务。 61 | - `After`:指定服务启动依赖,`network.target` 表示在网络服务启动后启动此服务。 62 | 2. **服务运行参数**: 63 | - `User` 和 `Group`:指定运行服务的用户和用户组。 64 | - `WorkingDirectory`:设置服务的工作目录。 65 | - `ExecStart`:定义启动服务的命令,包括 Uvicorn 的路径、模块和应用名称,端口和日志级别。 66 | - `Restart`:出错时自动重启服务。 67 | - `KillSignal`:指定终止服务的信号。 68 | - `TimeoutStopSec`:服务停止超时时间。 69 | - `PrivateTmp`:为服务提供独立的临时空间。 70 | 3. **环境变量**: 71 | - `Environment`:定义必要的环境变量,如数据库 URL 和安全密钥。 72 | 73 | 配置文件: 74 | 75 | ```ini 76 | [Unit] 77 | Description=Uvicorn Server for FastAPI 78 | After=network.target 79 | 80 | [Service] 81 | User=ubuntu 82 | Group=www-data 83 | WorkingDirectory=/home/ubuntu/paradigm-reboot-prober-backend 84 | ExecStart=/home/ubuntu/paradigm-reboot-prober-backend/venv/bin/uvicorn backend.main:app --port 8000 --log-level debug 85 | Restart=always 86 | KillSignal=SIGTERM 87 | TimeoutStopSec=5 88 | PrivateTmp=true 89 | Environment="PRP_DATABASE_URL=postgresql://:@/" 90 | Environment="PRP_SECRETE_KEY=xxxx" // 一个hex32的key, 具体内容请进入Server查看 91 | 92 | [Install] 93 | WantedBy=multi-user.target 94 | ``` 95 | 96 | 首先创建配置文件: 97 | 98 | ```apl 99 | sudo nano /etc/systemd/system/uvicorn.service 100 | ``` 101 | 102 | 然后填入上述内容,Ctrl+O保存,Ctrl+X退出。其中,`SECRETE_KEY`可以使用`SSL`生成: 103 | 104 | ```apl 105 | openssl rand -hex 32 106 | ``` 107 | 108 | ### 步骤 3: 使用Nginx和Cerbot建立Https服务 109 | 110 | **安装Nginx** 111 | 112 | ```apl 113 | sudo apt update 114 | sudo apt install nginx 115 | nginx -v //检查 Nginx 是否已安装 116 | sudo systemctl status nginx // 检查 Nginx 是否在运行 117 | ``` 118 | 119 | **安装必要的软件包** 120 | 121 | Certbot 依赖一些软件包,需要确保这些包在系统中可用。 122 | 123 | ```apl 124 | sudo apt-get install software-properties-common 125 | sudo add-apt-repository universe 126 | sudo apt-get update 127 | ``` 128 | 129 | 这些工具将帮助自动化从 Let’s Encrypt 获取证书并配置 Nginx。 130 | 131 | ```apl 132 | sudo apt-get install certbot python3-certbot-nginx 133 | ``` 134 | 135 | **使用 Certbot 配置 SSL 证书** 136 | 137 | Certbot 会修改 Nginx 配置以安全地提供 HTTPS 服务。这个过程包括生成新的 SSL 证书并更新 Nginx 配置以使用这些证书。 138 | 139 | ```apl 140 | sudo certbot --nginx 141 | ``` 142 | 143 | 在运行过程中,Certbot 会提示你选择一个或多个域名,为其配置证书。它还可能询问是否重定向 HTTP 流量到 HTTPS,这是推荐的做法。 144 | 145 | **设置证书自动续期** 146 | 147 | SSL 证书有有效期,通常是 90 天。Certbot 提供了自动续期的功能。 148 | 149 | ```apl 150 | sudo certbot renew --dry-run 151 | ``` 152 | 153 | 这个命令会测试自动续期过程,以确保在证书到期前自动更新。 154 | 155 | **测试 SSL 证书安装** 156 | 157 | ```apl 158 | openssl s_client -connect api.prp.icel.site:443 -servername api.prp.icel.site 159 | ``` 160 | 161 | 这条命令会显示 SSL 握手的详细信息,包括证书链和任何错误。 162 | 163 | **Nginx 配置为使用 SSL/TLS 证书** 164 | 165 | 为 Nginx 配置了 SSL/TLS 支持。 166 | 167 | ```js 168 | server { 169 | listen 443 ssl; 170 | server_name api.prp.icel.site; 171 | ssl_certificate /etc/letsencrypt/live/api.prp.icel.site/fullchain.pem; 172 | ssl_certificate_key /etc/letsencrypt/live/api.prp.icel.site/privkey.pem; 173 | location / { 174 | proxy_pass http://localhost:8000; 175 | proxy_set_header Host $host; 176 | proxy_set_header X-Real-IP $remote_addr; 177 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 178 | proxy_set_header X-Forwarded-Proto $scheme; 179 | } 180 | } 181 | ``` 182 | 183 | `listen 443 ssl;`: 监听 443 端口,启用 SSL。 184 | 185 | `ssl_certificate` 和 `ssl_certificate_key`: 指定 Let's Encrypt 提供的 SSL 证书和密钥文件的路径。 186 | 187 | `proxy_pass http://localhost:8000;`: 将所有传入请求代理到本地的 8000 端口,即 Uvicorn 服务器。 188 | 189 | `proxy_set_header`: 设置一些重要的 HTTP 头部,确保正确的客户端信息被传递给后端应用。 190 | 191 | 通常,Nginx 默认配置文件会在 `/etc/nginx/sites-enabled/default` 中,可能会与你的自定义配置冲突。禁用默认配置的方法如下: 192 | 193 | - **创建配置文件** 194 | 195 | ```apl 196 | sudo nano /etc/nginx/sites-available/api.prp.icel.site 197 | ``` 198 | 199 | - **启用配置** 200 | 201 | ```apl 202 | sudo ln -s /etc/nginx/sites-available/api.prp.icel.site /etc/nginx/sites-enabled/ 203 | ``` 204 | 205 | - **移除默认站点的链接** 206 | 207 | ```apl 208 | sudo rm /etc/nginx/sites-enabled/default 209 | ``` 210 | 211 | - **检查 Nginx 配置语法** 212 | 213 | ```apl 214 | sudo nginx -t 215 | ``` 216 | 217 | - **重载 Nginx** 218 | 219 | ```apl 220 | sudo systemctl reload nginx 221 | ``` 222 | 223 | 如果一切顺利,项目将能够在HTTPS下访问。 224 | 225 | ### 步骤 4: 启动和管理服务 226 | 227 | **启动服务**: 228 | 229 | ```apl 230 | sudo systemctl start uvicorn.server 231 | ``` 232 | 233 | **启用服务自启**: 使服务在系统启动时自动启动。 234 | 235 | ```apl 236 | sudo systemctl enable uvicorn.server 237 | ``` 238 | 239 | **检查服务状态**: 确保服务正在运行。 240 | 241 | ```apl 242 | sudo systemctl status uvicorn.server 243 | ``` 244 | 245 | **查看服务日志**: 查看服务的详细运行日志,以便于调试和监控。 246 | 247 | ```apl 248 | sudo journalctl -u uvicorn.server 249 | ``` 250 | 251 | **重新启动**`Uvicorn`服务,输入以下命令: 252 | 253 | ```apl 254 | sudo systemctl daemon-reload 255 | sudo systemctl restart uvicorn.service 256 | ``` 257 | 258 | ### 步骤 5: 维护和更新 259 | 260 | 本后端使用`Github Workflow`来进行自动化部署,在repo的`Secrets`加入以下三个变量: 261 | 262 | ```apl 263 | secrets.SERVER_IP // 服务器IP 264 | secrets.SERVER_USER // 服务器用户名 265 | secrets.SSH_PRIVATE_KEY // RSA私钥 266 | ``` 267 | 268 | 然后使用以下工作流,添加至项目目录下`/.github/workflow/`: 269 | 270 | ```yml 271 | name: CI/CD Pipeline for FastAPI 272 | 273 | on: 274 | push: 275 | branches: 276 | - master 277 | 278 | jobs: 279 | deploy: 280 | runs-on: ubuntu-latest 281 | steps: 282 | - name: Checkout code 283 | uses: actions/checkout@v2 284 | 285 | - name: SSH and Deploy 286 | uses: appleboy/ssh-action@master 287 | with: 288 | host: ${{ secrets.SERVER_IP }} 289 | username: ${{ secrets.SERVER_USER }} 290 | key: ${{ secrets.SSH_PRIVATE_KEY }} 291 | script: | 292 | cd /home/ubuntu/paradigm-reboot-prober-backend 293 | git reset --hard 294 | git clean -fd 295 | git fetch 296 | git pull 297 | source venv/bin/activate 298 | pip install -r requirements.txt 299 | sudo systemctl daemon-reload 300 | sudo systemctl restart uvicorn.service 301 | sudo systemctl status uvicorn.service 302 | ``` 303 | 304 | 之后,任何**push到master分支**的行为都将使得服务器进行自我更新,无需手动上传。 305 | 306 | ------ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bce-python-sdk==0.9.6 2 | bcrypt==4.1.1 3 | cryptography==42.0.5 4 | fastapi==0.110.1 5 | fastapi-cache2==0.2.1 6 | fastjsonschema==2.19.1 7 | passlib==1.7.4 8 | pillow==10.3.0 9 | pycryptodome==3.20.0 10 | pydantic==2.5.3 11 | pydantic_core==2.14.6 12 | python-jose==3.3.0 13 | python-multipart==0.0.7 14 | requests==2.32.2 15 | SQLAlchemy==2.0.25 16 | thefuzz==0.22.1 17 | python-dateutil~=2.9.0.post0 18 | paddleocr~=2.7.3 19 | slowapi~=0.1.9 20 | psycopg2-binary 21 | pydantic[email] -------------------------------------------------------------------------------- /resources/sql.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/resources/sql.db -------------------------------------------------------------------------------- /temp/upload/b50csv/maxscore.csv: -------------------------------------------------------------------------------- 1 | song_level_id,title,version,difficulty,level,score 2 | 150,Viyella's Melancholy,1.5.0,Massive,16.8,1010000 3 | 222,Rrhar'il,1.4.1,Massive,16.7,1010000 4 | 66,Cybernetic Vampire,2.0.0,Massive,16.6,1010000 5 | 102,クリムゾン帝王,2.2.0,Massive,16.5,1010000 6 | 420,Re:End of a Dream,1.9.0,Massive,16.5,1010000 7 | 90,天使光輪,2.2.0,Massive,16.4,1010000 8 | 126,Hemisphere,2.1.0,Massive,16.4,1010000 9 | 174,Secret Maneuvers,1.0.0,Massive,16.4,1010000 10 | 114,ほしぞらトラベル,1.8.0,Massive,16.3,1010000 11 | 267,Avantgarde,2.2.0,Massive,16.3,1010000 12 | 45,[NWAD],1.0.0,Massive,16.2,1010000 13 | 78,桜花怨雷,2.3.0,Massive,16.2,1010000 14 | 162,Indelible Scar,1.0.0,Massive,16.2,1010000 15 | 27,Rondo of the 3ND,1.0.0,Massive,16.1,1010000 16 | 387,キミとボクへの葬送歌,1.0.0,Massive,16.1,1010000 17 | 138,夏色の思い出,1.7.0,Massive,16,1010000 18 | 186,Equality of Equites,1.2.0,Massive,16,1010000 19 | 87,ARTEMiS,2.2.0,Massive,15.9,1010000 20 | 99,Dimension Hacker,2.2.0,Massive,15.9,1010000 21 | 111,Ashes,1.8.0,Massive,15.9,1010000 22 | 411,Destr0yer,1.6.0,Massive,15.9,1010000 23 | 417,CO5M1C R4ILR0AD,2.3.1,Massive,15.9,1010000 24 | 12,Restricted Access,1.0.0,Massive,15.8,1010000 25 | 24,Verreta,1.0.0,Massive,15.8,1010000 26 | 63,Echoes of the Forest,2.0.0,Massive,15.8,1010000 27 | 339,Fracture Temporelle,1.9.2,Massive,15.8,1010000 28 | 39,爆裂魔法,1.0.0,Massive,15.7,1010000 29 | 123,Kokytos,2.1.0,Massive,15.7,1010000 30 | 135,Veil of Summer,1.7.0,Massive,15.7,1010000 31 | 207,席替えやったー!,2.0.0,Massive,15.7,1010000 32 | 342,Unpredictable Nascent Invasion,1.9.3,Massive,15.7,1010000 33 | 345,RE; Boot,2.0.0,Massive,15.7,1010000 34 | 363,Alexandrite,2.3.1,Massive,15.7,1010000 35 | 42,[PRAW],1.0.0,Massive,15.6,1010000 36 | 75,Kakuriyo,2.3.0,Massive,15.6,1010000 37 | 120,Last farewell,2.1.0,Massive,15.6,1010000 38 | 159,REDRAVE,1.0.0,Massive,15.6,1010000 39 | 219,Black:Magnam,1.3.0,Massive,15.6,1010000 40 | 294,Subterranean Blastoff,1.0.0,Massive,15.6,1010000 41 | 333,felys -final remix-,1.9.0,Massive,15.6,1010000 42 | 30,Forgotten Asteroid,1.0.0,Massive,15.5,1010000 43 | 147,Yokyuumi,1.5.0,Massive,15.5,1010000 44 | 234,BPM=RT,1.4.1,Massive,15.5,1010000 45 | 360,Disintegrate of Eden,2.3.1,Massive,15.5,1010000 46 | 60,The Grand F-STAR,2.0.0,Massive,15.4,1010000 47 | 156,Marsh Winds,1.0.0,Massive,15.4,1010000 48 | 195,macro.wav,1.4.1,Massive,15.4,1010000 49 | 321,Grilled Cheese Burger,1.6.1,Massive,15.4,1010000 50 | 36,零號車輛,1.0.0,Massive,15.3,1010000 51 | 144,Rebooted Mind,1.5.0,Massive,15.3,1010000 52 | 291,Ouvertüre,2.2.0,Massive,15.3,1010000 53 | 96,Chariot,2.2.0,Massive,15.2,1010000 54 | 240,REDASH,1.9.0,Massive,15.2,1010000 55 | 261,conflict,2.2.0,Massive,15.2,1010000 56 | 300,resoLve,1.0.0,Massive,15.2,1010000 57 | 327,Pantomime,1.7.1,Massive,15.2,1010000 58 | 414,c.s.q.n.,1.6.0,Massive,15.2,1010000 59 | 57,Blessed Bleath,2.0.0,Massive,15.1,1010000 60 | 84,猫娘,2.2.0,Massive,15.1,1010000 61 | 171,Reversed Zenith,1.0.0,Massive,15.1,1010000 62 | 198,Paradial Resonator,1.4.1,Massive,15.1,1010000 63 | 273,WORLDCALL,2.2.0,Massive,15.1,1010000 64 | 390,Cross†Ray,1.6.0,Massive,15.1,1010000 65 | 405,Chronostasis,1.0.0,Massive,15.1,1010000 66 | 237,Inner Norm,1.4.1,Massive,15,1010000 67 | 318,Xenolith,1.4.2,Massive,15,1010000 68 | 336,TOT,1.9.1,Massive,15,1010000 69 | 33,FATE FIRE,1.0.0,Massive,14.9, 70 | 81,Knight Rider,2.2.0,Massive,14.9, 71 | 93,EPHMR,2.2.0,Massive,14.9, 72 | 168,Protection,1.0.0,Massive,14.9, 73 | 306,IKAROS,1.0.0,Massive,14.9, 74 | 72,胡蝶乃舞,2.3.0,Massive,14.8, 75 | 204,天Q.,2.0.0,Massive,14.8, 76 | 228,Chartreuse Green,1.4.1,Massive,14.8, 77 | 315,White:Revolve,1.3.0,Massive,14.8, 78 | 153,Tequila Sunrise And Planter's Punch,1.0.0,Massive,14.7, 79 | 372,Cipher:/2&//<|0,1.0.0,Massive,14.6, 80 | 180,SUTTA MONDAY,1.2.0,Massive,14.5, 81 | 210,Save And Continue,1.0.0,Massive,14.5, 82 | 264,Kronos,2.2.0,Massive,14.5, 83 | 288,Let you DIVE!,2.2.0,Massive,14.5, 84 | 246,Oriens,1.9.2,Massive,14.4, 85 | 252,Paradigm Shift,2.0.0,Massive,14.4, 86 | 309,The Night City,1.1.0,Massive,14.4, 87 | 330,Artificial Existence,1.9.0,Massive,14.4, 88 | 351,カラッポ・ノンフィクション,2.1.1,Massive,14.4, 89 | 132,Heartpoint,1.7.0,Massive,14.3, 90 | 165,Turning POINT,1.0.0,Massive,14.3, 91 | 243,OMG,1.9.1,Massive,14.3, 92 | 249,Houkago OVERDRIVE!!!!!,1.9.3,Massive,14.3, 93 | 54,Incineraxion,2.0.0,Massive,14.2, 94 | 108,Invaded,1.8.0,Massive,14.2, 95 | 141,#D3D3D3 (Blank Universe),1.5.0,Massive,14.2, 96 | 255,Fading Star,2.0.1,Massive,14.2, 97 | 303,On And On!!,1.0.0,Massive,14.2, 98 | 324,Divergence,1.7.1,Massive,14.2, 99 | 285,恶修女——永火熔铸 (feat. 黑泽诺亚NOIR),2.3.0,Massive,14.1, 100 | 312,Gray:MachineGang,1.3.0,Massive,14.1, 101 | 348,Midnight Fairy Tale,2.0.1,Massive,14.1, 102 | 384,VEZZELiX,1.0.0,Massive,14.1, 103 | 393,EVERYTHING,2.0.0,Massive,14.1, 104 | 105,Little Boy,1.8.0,Massive,14, 105 | 107,Invaded,1.8.0,Invaded,14, 106 | 396,Etude -Sunset-,1.0.0,Massive,14, 107 | 15,虚,1.0.0,Massive,13.9, 108 | 117,Heart Eyes,2.1.0,Massive,13.9, 109 | 149,Viyella's Melancholy,1.5.0,Invaded,13.8, 110 | 69,緋星,2.3.0,Massive,13.7, 111 | 201,devil¿la6ue,2.0.0,Massive,13.7, 112 | 258,アイム・マイヒーロー,2.1.1,Massive,13.7, 113 | 177,OCCHOCO-REST-LESS,1.2.0,Massive,13.6, 114 | 270,MilK,2.2.0,Massive,13.6, 115 | 48,Only One Light,1.0.0,Massive,13.5, 116 | 9,Press,1.0.0,Massive,13.4, 117 | 192,Clock Paradox,1.4.1,Massive,13.4, 118 | 408,Anökumene,1.6.0,Massive,13.4, 119 | 98,Dimension Hacker,2.2.0,Invaded,13.3, 120 | 279,爱上想象的你 (feat. 穆小泠),2.3.0,Massive,13.3, 121 | 375,Vault of Sky,1.0.0,Massive,13.3, 122 | 213,Yorixiro,1.0.0,Massive,13.2, 123 | 225,Psyched Fevereiro,1.4.1,Massive,13.2, 124 | 18,Lucid Trigger,1.0.0,Massive,13.1, 125 | 129,Mint Choco,1.7.0,Massive,13.1, 126 | 51,静寂に憂う,2.0.0,Massive,13, 127 | 65,Cybernetic Vampire,2.0.0,Invaded,13, 128 | 231,Dogbite,1.4.1,Massive,13, 129 | 21,Time's Up,1.0.0,Massive,12.5, 130 | 183,Hiyaiya!,1.2.0,Massive,12.5, 131 | 189,Skyscape,1.4.1,Massive,12.5, 132 | 276,今天不是明天 (feat. 兰音Reine),2.2.0,Massive,12.5, 133 | 297,Encore,1.0.0,Massive,12.5, 134 | 381,Cereris,1.0.0,Massive,12.5, 135 | 6,Chase,1.0.0,Massive,12, 136 | 23,Verreta,1.0.0,Invaded,12, 137 | 26,Rondo of the 3ND,1.0.0,Invaded,12, 138 | 44,[NWAD],1.0.0,Invaded,12, 139 | 74,Kakuriyo,2.3.0,Invaded,12, 140 | 89,天使光輪,2.2.0,Invaded,12, 141 | 101,クリムゾン帝王,2.2.0,Invaded,12, 142 | 119,Last farewell,2.1.0,Invaded,12, 143 | 216,Can't it be true,1.1.0,Massive,12, 144 | 266,Avantgarde,2.2.0,Invaded,12, 145 | 354,月下桜天國,2.2.0,Massive,12, 146 | 357,庚子桜天國,2.2.0,Massive,12, 147 | 362,Alexandrite,2.3.1,Invaded,12, 148 | 366,Class Memories,1.0.0,Massive,12, 149 | 386,キミとボクへの葬送歌,1.0.0,Invaded,12, 150 | 416,CO5M1C R4ILR0AD,2.3.1,Invaded,12, 151 | 419,Re:End of a Dream,1.9.0,Invaded,12, 152 | 41,[PRAW],1.0.0,Invaded,11.5, 153 | 71,胡蝶乃舞,2.3.0,Invaded,11.5, 154 | 77,桜花怨雷,2.3.0,Invaded,11.5, 155 | 134,Veil of Summer,1.7.0,Invaded,11.5, 156 | 137,夏色の思い出,1.7.0,Invaded,11.5, 157 | 146,Yokyuumi,1.5.0,Invaded,11.5, 158 | 173,Secret Maneuvers,1.0.0,Invaded,11.5, 159 | 197,Paradial Resonator,1.4.1,Invaded,11.5, 160 | 221,Rrhar'il,1.4.1,Invaded,11.5, 161 | 260,conflict,2.2.0,Invaded,11.5, 162 | 282,甜涩之梦 (feat. 早稻叽),2.3.0,Massive,11.5, 163 | 290,Ouvertüre,2.2.0,Invaded,11.5, 164 | 344,RE; Boot,2.0.0,Invaded,11.5, 165 | 395,Etude -Sunset-,1.0.0,Invaded,11.5, 166 | 35,零號車輛,1.0.0,Invaded,11, 167 | 56,Blessed Bleath,2.0.0,Invaded,11, 168 | 62,Echoes of the Forest,2.0.0,Invaded,11, 169 | 113,ほしぞらトラベル,1.8.0,Invaded,11, 170 | 125,Hemisphere,2.1.0,Invaded,11, 171 | 143,Rebooted Mind,1.5.0,Invaded,11, 172 | 161,Indelible Scar,1.0.0,Invaded,11, 173 | 206,席替えやったー!,2.0.0,Invaded,11, 174 | 308,The Night City,1.1.0,Invaded,11, 175 | 359,Disintegrate of Eden,2.3.1,Invaded,11, 176 | 369,Winter ↑cube↓,1.0.0,Massive,11, 177 | 378,Burn,1.0.0,Massive,11, 178 | 399,Pulsar,1.0.0,Massive,11, 179 | 402,Selenotaxis,1.9.0,Massive,11, 180 | 410,Destr0yer,1.6.0,Invaded,11, 181 | 3,Awaken In Ruins,1.0.0,Massive,10.5, 182 | 11,Restricted Access,1.0.0,Invaded,10.5, 183 | 32,FATE FIRE,1.0.0,Invaded,10.5, 184 | 53,Incineraxion,2.0.0,Invaded,10.5, 185 | 59,The Grand F-STAR,2.0.0,Invaded,10.5, 186 | 83,猫娘,2.2.0,Invaded,10.5, 187 | 104,Little Boy,1.8.0,Invaded,10.5, 188 | 110,Ashes,1.8.0,Invaded,10.5, 189 | 116,Heart Eyes,2.1.0,Invaded,10.5, 190 | 131,Heartpoint,1.7.0,Invaded,10.5, 191 | 155,Marsh Winds,1.0.0,Invaded,10.5, 192 | 185,Equality of Equites,1.2.0,Invaded,10.5, 193 | 239,REDASH,1.9.0,Invaded,10.5, 194 | 245,Oriens,1.9.2,Invaded,10.5, 195 | 314,White:Revolve,1.3.0,Invaded,10.5, 196 | 320,Grilled Cheese Burger,1.6.1,Invaded,10.5, 197 | 326,Pantomime,1.7.1,Invaded,10.5, 198 | 332,felys -final remix-,1.9.0,Invaded,10.5, 199 | 338,Fracture Temporelle,1.9.2,Invaded,10.5, 200 | 350,カラッポ・ノンフィクション,2.1.1,Invaded,10.5, 201 | 389,Cross†Ray,1.6.0,Invaded,10.5, 202 | 404,Chronostasis,1.0.0,Invaded,10.5, 203 | 29,Forgotten Asteroid,1.0.0,Invaded,10, 204 | 68,緋星,2.3.0,Invaded,10, 205 | 80,Knight Rider,2.2.0,Invaded,10, 206 | 86,ARTEMiS,2.2.0,Invaded,10, 207 | 95,Chariot,2.2.0,Invaded,10, 208 | 122,Kokytos,2.1.0,Invaded,10, 209 | 128,Mint Choco,1.7.0,Invaded,10, 210 | 140,#D3D3D3 (Blank Universe),1.5.0,Invaded,10, 211 | 164,Turning POINT,1.0.0,Invaded,10, 212 | 167,Protection,1.0.0,Invaded,10, 213 | 170,Reversed Zenith,1.0.0,Invaded,10, 214 | 179,SUTTA MONDAY,1.2.0,Invaded,10, 215 | 194,macro.wav,1.4.1,Invaded,10, 216 | 209,Save And Continue,1.0.0,Invaded,10, 217 | 218,Black:Magnam,1.3.0,Invaded,10, 218 | 233,BPM=RT,1.4.1,Invaded,10, 219 | 287,Let you DIVE!,2.2.0,Invaded,10, 220 | 293,Subterranean Blastoff,1.0.0,Invaded,10, 221 | 299,resoLve,1.0.0,Invaded,10, 222 | 311,Gray:MachineGang,1.3.0,Invaded,10, 223 | 392,EVERYTHING,2.0.0,Invaded,10, 224 | 413,c.s.q.n.,1.6.0,Invaded,10, 225 | 14,虚,1.0.0,Invaded,9.5, 226 | 47,Only One Light,1.0.0,Invaded,9.5, 227 | 203,天Q.,2.0.0,Invaded,9.5, 228 | 236,Inner Norm,1.4.1,Invaded,9.5, 229 | 242,OMG,1.9.1,Invaded,9.5, 230 | 248,Houkago OVERDRIVE!!!!!,1.9.3,Invaded,9.5, 231 | 251,Paradigm Shift,2.0.0,Invaded,9.5, 232 | 257,アイム・マイヒーロー,2.1.1,Invaded,9.5, 233 | 272,WORLDCALL,2.2.0,Invaded,9.5, 234 | 284,恶修女——永火熔铸 (feat. 黑泽诺亚NOIR),2.3.0,Invaded,9.5, 235 | 317,Xenolith,1.4.2,Invaded,9.5, 236 | 323,Divergence,1.7.1,Invaded,9.5, 237 | 329,Artificial Existence,1.9.0,Invaded,9.5, 238 | 341,Unpredictable Nascent Invasion,1.9.3,Invaded,9.5, 239 | 347,Midnight Fairy Tale,2.0.1,Invaded,9.5, 240 | 8,Press,1.0.0,Invaded,9, 241 | 17,Lucid Trigger,1.0.0,Invaded,9, 242 | 38,爆裂魔法,1.0.0,Invaded,9, 243 | 92,EPHMR,2.2.0,Invaded,9, 244 | 152,Tequila Sunrise And Planter's Punch,1.0.0,Invaded,9, 245 | 191,Clock Paradox,1.4.1,Invaded,9, 246 | 200,devil¿la6ue,2.0.0,Invaded,9, 247 | 227,Chartreuse Green,1.4.1,Invaded,9, 248 | 230,Dogbite,1.4.1,Invaded,9, 249 | 263,Kronos,2.2.0,Invaded,9, 250 | 269,MilK,2.2.0,Invaded,9, 251 | 281,甜涩之梦 (feat. 早稻叽),2.3.0,Invaded,9, 252 | 296,Encore,1.0.0,Invaded,9, 253 | 302,On And On!!,1.0.0,Invaded,9, 254 | 305,IKAROS,1.0.0,Invaded,9, 255 | 371,Cipher:/2&//<|0,1.0.0,Invaded,9, 256 | 380,Cereris,1.0.0,Invaded,9, 257 | 383,VEZZELiX,1.0.0,Invaded,9, 258 | 5,Chase,1.0.0,Invaded,8, 259 | 20,Time's Up,1.0.0,Invaded,8, 260 | 157,REDRAVE,1.0.0,Detected,8, 261 | 158,REDRAVE,1.0.0,Invaded,8, 262 | 176,OCCHOCO-REST-LESS,1.2.0,Invaded,8, 263 | 212,Yorixiro,1.0.0,Invaded,8, 264 | 215,Can't it be true,1.1.0,Invaded,8, 265 | 224,Psyched Fevereiro,1.4.1,Invaded,8, 266 | 254,Fading Star,2.0.1,Invaded,8, 267 | 275,今天不是明天 (feat. 兰音Reine),2.2.0,Invaded,8, 268 | 335,TOT,1.9.1,Invaded,8, 269 | 374,Vault of Sky,1.0.0,Invaded,8, 270 | 377,Burn,1.0.0,Invaded,8, 271 | 398,Pulsar,1.0.0,Invaded,8, 272 | 407,Anökumene,1.6.0,Invaded,8, 273 | 50,静寂に憂う,2.0.0,Invaded,7, 274 | 148,Viyella's Melancholy,1.5.0,Detected,7, 275 | 182,Hiyaiya!,1.2.0,Invaded,7, 276 | 188,Skyscape,1.4.1,Invaded,7, 277 | 278,爱上想象的你 (feat. 穆小泠),2.3.0,Invaded,7, 278 | 353,月下桜天國,2.2.0,Invaded,7, 279 | 356,庚子桜天國,2.2.0,Invaded,7, 280 | 365,Class Memories,1.0.0,Invaded,7, 281 | 368,Winter ↑cube↓,1.0.0,Invaded,7, 282 | 401,Selenotaxis,1.9.0,Invaded,7, 283 | 2,Awaken In Ruins,1.0.0,Invaded,6, 284 | 25,Rondo of the 3ND,1.0.0,Detected,6, 285 | 43,[NWAD],1.0.0,Detected,6, 286 | 64,Cybernetic Vampire,2.0.0,Detected,6, 287 | 88,天使光輪,2.2.0,Detected,6, 288 | 100,クリムゾン帝王,2.2.0,Detected,6, 289 | 109,Ashes,1.8.0,Detected,6, 290 | 160,Indelible Scar,1.0.0,Detected,6, 291 | 172,Secret Maneuvers,1.0.0,Detected,6, 292 | 220,Rrhar'il,1.4.1,Detected,6, 293 | 418,Re:End of a Dream,1.9.0,Detected,6, 294 | 10,Restricted Access,1.0.0,Detected,5, 295 | 22,Verreta,1.0.0,Detected,5, 296 | 28,Forgotten Asteroid,1.0.0,Detected,5, 297 | 46,Only One Light,1.0.0,Detected,5, 298 | 55,Blessed Bleath,2.0.0,Detected,5, 299 | 97,Dimension Hacker,2.2.0,Detected,5, 300 | 106,Invaded,1.8.0,Detected,5, 301 | 112,ほしぞらトラベル,1.8.0,Detected,5, 302 | 124,Hemisphere,2.1.0,Detected,5, 303 | 133,Veil of Summer,1.7.0,Detected,5, 304 | 136,夏色の思い出,1.7.0,Detected,5, 305 | 142,Rebooted Mind,1.5.0,Detected,5, 306 | 166,Protection,1.0.0,Detected,5, 307 | 169,Reversed Zenith,1.0.0,Detected,5, 308 | 196,Paradial Resonator,1.4.1,Detected,5, 309 | 205,席替えやったー!,2.0.0,Detected,5, 310 | 211,Yorixiro,1.0.0,Detected,5, 311 | 226,Chartreuse Green,1.4.1,Detected,5, 312 | 238,REDASH,1.9.0,Detected,5, 313 | 259,conflict,2.2.0,Detected,5, 314 | 265,Avantgarde,2.2.0,Detected,5, 315 | 292,Subterranean Blastoff,1.0.0,Detected,5, 316 | 295,Encore,1.0.0,Detected,5, 317 | 298,resoLve,1.0.0,Detected,5, 318 | 301,On And On!!,1.0.0,Detected,5, 319 | 316,Xenolith,1.4.2,Detected,5, 320 | 337,Fracture Temporelle,1.9.2,Detected,5, 321 | 343,RE; Boot,2.0.0,Detected,5, 322 | 358,Disintegrate of Eden,2.3.1,Detected,5, 323 | 370,Cipher:/2&//<|0,1.0.0,Detected,5, 324 | 385,キミとボクへの葬送歌,1.0.0,Detected,5, 325 | 394,Etude -Sunset-,1.0.0,Detected,5, 326 | 403,Chronostasis,1.0.0,Detected,5, 327 | 409,Destr0yer,1.6.0,Detected,5, 328 | 412,c.s.q.n.,1.6.0,Detected,5, 329 | 415,CO5M1C R4ILR0AD,2.3.1,Detected,5, 330 | 7,Press,1.0.0,Detected,4, 331 | 31,FATE FIRE,1.0.0,Detected,4, 332 | 34,零號車輛,1.0.0,Detected,4, 333 | 40,[PRAW],1.0.0,Detected,4, 334 | 52,Incineraxion,2.0.0,Detected,4, 335 | 58,The Grand F-STAR,2.0.0,Detected,4, 336 | 61,Echoes of the Forest,2.0.0,Detected,4, 337 | 70,胡蝶乃舞,2.3.0,Detected,4, 338 | 73,Kakuriyo,2.3.0,Detected,4, 339 | 76,桜花怨雷,2.3.0,Detected,4, 340 | 79,Knight Rider,2.2.0,Detected,4, 341 | 82,猫娘,2.2.0,Detected,4, 342 | 85,ARTEMiS,2.2.0,Detected,4, 343 | 115,Heart Eyes,2.1.0,Detected,4, 344 | 118,Last farewell,2.1.0,Detected,4, 345 | 127,Mint Choco,1.7.0,Detected,4, 346 | 130,Heartpoint,1.7.0,Detected,4, 347 | 139,#D3D3D3 (Blank Universe),1.5.0,Detected,4, 348 | 145,Yokyuumi,1.5.0,Detected,4, 349 | 154,Marsh Winds,1.0.0,Detected,4, 350 | 178,SUTTA MONDAY,1.2.0,Detected,4, 351 | 184,Equality of Equites,1.2.0,Detected,4, 352 | 202,天Q.,2.0.0,Detected,4, 353 | 208,Save And Continue,1.0.0,Detected,4, 354 | 223,Psyched Fevereiro,1.4.1,Detected,4, 355 | 229,Dogbite,1.4.1,Detected,4, 356 | 244,Oriens,1.9.2,Detected,4, 357 | 253,Fading Star,2.0.1,Detected,4, 358 | 262,Kronos,2.2.0,Detected,4, 359 | 274,今天不是明天 (feat. 兰音Reine),2.2.0,Detected,4, 360 | 283,恶修女——永火熔铸 (feat. 黑泽诺亚NOIR),2.3.0,Detected,4, 361 | 286,Let you DIVE!,2.2.0,Detected,4, 362 | 289,Ouvertüre,2.2.0,Detected,4, 363 | 304,IKAROS,1.0.0,Detected,4, 364 | 307,The Night City,1.1.0,Detected,4, 365 | 313,White:Revolve,1.3.0,Detected,4, 366 | 328,Artificial Existence,1.9.0,Detected,4, 367 | 331,felys -final remix-,1.9.0,Detected,4, 368 | 334,TOT,1.9.1,Detected,4, 369 | 346,Midnight Fairy Tale,2.0.1,Detected,4, 370 | 349,カラッポ・ノンフィクション,2.1.1,Detected,4, 371 | 361,Alexandrite,2.3.1,Detected,4, 372 | 379,Cereris,1.0.0,Detected,4, 373 | 382,VEZZELiX,1.0.0,Detected,4, 374 | 4,Chase,1.0.0,Detected,3, 375 | 19,Time's Up,1.0.0,Detected,3, 376 | 37,爆裂魔法,1.0.0,Detected,3, 377 | 67,緋星,2.3.0,Detected,3, 378 | 91,EPHMR,2.2.0,Detected,3, 379 | 94,Chariot,2.2.0,Detected,3, 380 | 103,Little Boy,1.8.0,Detected,3, 381 | 121,Kokytos,2.1.0,Detected,3, 382 | 151,Tequila Sunrise And Planter's Punch,1.0.0,Detected,3, 383 | 163,Turning POINT,1.0.0,Detected,3, 384 | 175,OCCHOCO-REST-LESS,1.2.0,Detected,3, 385 | 181,Hiyaiya!,1.2.0,Detected,3, 386 | 187,Skyscape,1.4.1,Detected,3, 387 | 190,Clock Paradox,1.4.1,Detected,3, 388 | 193,macro.wav,1.4.1,Detected,3, 389 | 199,devil¿la6ue,2.0.0,Detected,3, 390 | 214,Can't it be true,1.1.0,Detected,3, 391 | 217,Black:Magnam,1.3.0,Detected,3, 392 | 232,BPM=RT,1.4.1,Detected,3, 393 | 235,Inner Norm,1.4.1,Detected,3, 394 | 241,OMG,1.9.1,Detected,3, 395 | 247,Houkago OVERDRIVE!!!!!,1.9.3,Detected,3, 396 | 250,Paradigm Shift,2.0.0,Detected,3, 397 | 256,アイム・マイヒーロー,2.1.1,Detected,3, 398 | 268,MilK,2.2.0,Detected,3, 399 | 271,WORLDCALL,2.2.0,Detected,3, 400 | 280,甜涩之梦 (feat. 早稻叽),2.3.0,Detected,3, 401 | 310,Gray:MachineGang,1.3.0,Detected,3, 402 | 319,Grilled Cheese Burger,1.6.1,Detected,3, 403 | 322,Divergence,1.7.1,Detected,3, 404 | 325,Pantomime,1.7.1,Detected,3, 405 | 340,Unpredictable Nascent Invasion,1.9.3,Detected,3, 406 | 367,Winter ↑cube↓,1.0.0,Detected,3, 407 | 376,Burn,1.0.0,Detected,3, 408 | 388,Cross†Ray,1.6.0,Detected,3, 409 | 391,EVERYTHING,2.0.0,Detected,3, 410 | 397,Pulsar,1.0.0,Detected,3, 411 | 406,Anökumene,1.6.0,Detected,3, 412 | 13,虚,1.0.0,Detected,2, 413 | 16,Lucid Trigger,1.0.0,Detected,2, 414 | 277,爱上想象的你 (feat. 穆小泠),2.3.0,Detected,2, 415 | 352,月下桜天國,2.2.0,Detected,2, 416 | 355,庚子桜天國,2.2.0,Detected,2, 417 | 364,Class Memories,1.0.0,Detected,2, 418 | 373,Vault of Sky,1.0.0,Detected,2, 419 | 400,Selenotaxis,1.9.0,Detected,2, 420 | 1,Awaken In Ruins,1.0.0,Detected,1, 421 | 49,静寂に憂う,2.0.0,Detected,1, 422 | -------------------------------------------------------------------------------- /tools/create_levels.py: -------------------------------------------------------------------------------- 1 | import json 2 | from backend.prprober.model.schemas import SongCreate, LevelInfo 3 | from backend.prprober.util.database import get_db_sync 4 | from backend.prprober.crud.song import create_song 5 | 6 | 7 | def get_diff_str(diff_id: int): 8 | if diff_id == 1: 9 | return 'detected' 10 | elif diff_id == 2: 11 | return 'invaded' 12 | elif diff_id == 3: 13 | return 'massive' 14 | return None 15 | 16 | 17 | if __name__ == '__main__': 18 | with open("meta.json", "r") as f: 19 | meta = json.load(f) 20 | 21 | songs, songs_schema = meta['items'], [] 22 | title_id_map, id_song_level_map = {}, {} 23 | for song in songs: 24 | if song['isNewlyUpdated']: 25 | title_id_map[song['title']] = song['address'] 26 | songs_schema.append(SongCreate( 27 | title=song['title'], 28 | artist=song['musician'], 29 | genre=song['genre'], 30 | cover=f"{song['title']}.png", 31 | illustrator=song['illustrator'], 32 | version=meta['ver'], 33 | b15=True, 34 | album='Future MagnetiX', 35 | bpm=song['bpm'], 36 | length='?:??', 37 | song_levels=[LevelInfo( 38 | difficulty_id=chart['difficulty']+1, 39 | level=chart['level'], 40 | level_design=chart['noter'], 41 | notes=0 42 | ) for chart in song['charts']] 43 | )) 44 | 45 | db = get_db_sync() 46 | for song in songs_schema: 47 | song = create_song(db, song) 48 | for level in song.song_levels: 49 | id_song_level_map[title_id_map[song.title] + f'/{get_diff_str(level.difficulty_id)}'] =\ 50 | level.song_level_id 51 | 52 | with open('new_chart2level.json', 'w', encoding='utf-8') as f: 53 | json.dump(id_song_level_map, f) 54 | 55 | -------------------------------------------------------------------------------- /tools/import_wiki_id.py: -------------------------------------------------------------------------------- 1 | from backend.prprober.util.database import get_db_sync 2 | from backend.prprober.crud.song import get_single_song_by_id 3 | from backend.prprober.model.entities import Song 4 | 5 | import json 6 | 7 | 8 | if __name__ == "__main__": 9 | db = get_db_sync() 10 | with open("prp_to_wiki.json") as f: 11 | mapping = json.load(f) 12 | for song_id, wiki_id in mapping.items(): 13 | song: Song = get_single_song_by_id(db, int(song_id)) 14 | song.wiki_id = wiki_id 15 | db.commit() -------------------------------------------------------------------------------- /tools/update_levels.py: -------------------------------------------------------------------------------- 1 | import json 2 | from backend.prprober.util.database import get_db_sync 3 | from backend.prprober.crud.song import get_all_songs 4 | 5 | if __name__ == '__main__': 6 | 7 | db = get_db_sync() 8 | songs = get_all_songs(db) 9 | EPS = 0.0002 10 | 11 | diff_to_id = { 12 | 'detected': 1, 13 | 'invaded': 2, 14 | 'massive': 3 15 | } 16 | id_to_diff = { 17 | 1: "detected", 18 | 2: "invaded", 19 | 3: "massive" 20 | } 21 | title_to_id = {} 22 | 23 | with open('meta_modified.json') as f: 24 | songs_meta = json.load(f)['songs'] 25 | 26 | for song_id, info in songs_meta.items(): 27 | title_to_id[info['title']] = song_id 28 | 29 | for song in songs: 30 | for level in song.song_levels: 31 | meta_level = songs_meta[title_to_id[song.title]]['charts'][id_to_diff[level.difficulty_id]]['acclevel'] 32 | if abs(level.level - meta_level) > EPS and meta_level != 0: 33 | print(f"update level {song.title}[{id_to_diff[level.difficulty_id]}] {level.level}" 34 | f"->{meta_level}") 35 | level.level = meta_level 36 | 37 | db.commit() 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /tools/update_rating.py: -------------------------------------------------------------------------------- 1 | from backend.prprober.util.rating import single_rating 2 | from backend.prprober.util.database import get_db_sync 3 | from backend.prprober.model.entities import PlayRecord 4 | 5 | if __name__ == '__main__': 6 | db = get_db_sync() 7 | records = db.query(PlayRecord).all() 8 | for record in records: 9 | record.rating = single_rating(record.song_level.level, record.score) 10 | db.commit() -------------------------------------------------------------------------------- /unit_test/b50image/b50_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | 4 | from backend.prprober.model import PlayRecordInfo 5 | import backend.prprober.util.b50.img as b50 6 | 7 | 8 | class MyTestCase(unittest.TestCase): 9 | def test_something(self): 10 | with open('b50image/data.json', 'r', encoding='utf-8') as f: 11 | data = json.load(f) 12 | records = [PlayRecordInfo.model_validate(record) for record in data] 13 | b50.generate_b50_img(records, None) 14 | self.assertEqual(True, True) # add assertion here 15 | 16 | 17 | if __name__ == '__main__': 18 | unittest.main() 19 | -------------------------------------------------------------------------------- /unit_test/b50image/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "play_record_id": 59, 4 | "username": "icelocke", 5 | "record_time": "2024-04-14T21:11:58.763065", 6 | "score": 1003070, 7 | "rating": 17004, 8 | "song_level": { 9 | "title": "Viyella's Melancholy", 10 | "version": "1.5.0", 11 | "b15": false, 12 | "song_id": 50, 13 | "song_level_id": 150, 14 | "difficulty_id": 3, 15 | "difficulty": "Massive", 16 | "level": 16.8, 17 | "cover": "Viyella's Melancholy.png", 18 | "fitting_level": null 19 | } 20 | }, 21 | { 22 | "play_record_id": 14, 23 | "username": "icelocke", 24 | "record_time": "2024-04-08T22:57:03.974430", 25 | "score": 1007798, 26 | "rating": 16919, 27 | "song_level": { 28 | "title": "Secret Maneuvers", 29 | "version": "1.0.0", 30 | "b15": false, 31 | "song_id": 58, 32 | "song_level_id": 174, 33 | "difficulty_id": 3, 34 | "difficulty": "Massive", 35 | "level": 16.4, 36 | "cover": "Secret Maneuvers.png", 37 | "fitting_level": null 38 | } 39 | }, 40 | { 41 | "play_record_id": 54, 42 | "username": "icelocke", 43 | "record_time": "2024-04-14T21:08:47.642048", 44 | "score": 1009000, 45 | "rating": 16800, 46 | "song_level": { 47 | "title": "Indelible Scar", 48 | "version": "1.0.0", 49 | "b15": false, 50 | "song_id": 54, 51 | "song_level_id": 162, 52 | "difficulty_id": 3, 53 | "difficulty": "Massive", 54 | "level": 16.2, 55 | "cover": "Indelible Scar.png", 56 | "fitting_level": null 57 | } 58 | }, 59 | { 60 | "play_record_id": 11, 61 | "username": "icelocke", 62 | "record_time": "2024-04-08T22:56:27.086770", 63 | "score": 1006800, 64 | "rating": 16753, 65 | "song_level": { 66 | "title": "ほしぞらトラベル", 67 | "version": "1.8.0", 68 | "b15": false, 69 | "song_id": 38, 70 | "song_level_id": 114, 71 | "difficulty_id": 3, 72 | "difficulty": "Massive", 73 | "level": 16.3, 74 | "cover": "ほしぞらトラベル.png", 75 | "fitting_level": null 76 | } 77 | }, 78 | { 79 | "play_record_id": 16, 80 | "username": "icelocke", 81 | "record_time": "2024-04-08T22:58:22.241310", 82 | "score": 1002195, 83 | "rating": 16646, 84 | "song_level": { 85 | "title": "Re:End of a Dream", 86 | "version": "1.9.0", 87 | "b15": false, 88 | "song_id": 140, 89 | "song_level_id": 420, 90 | "difficulty_id": 3, 91 | "difficulty": "Massive", 92 | "level": 16.5, 93 | "cover": "Re:End of a Dream.png", 94 | "fitting_level": null 95 | } 96 | }, 97 | { 98 | "play_record_id": 21, 99 | "username": "icelocke", 100 | "record_time": "2024-04-08T22:59:24.826389", 101 | "score": 1006290, 102 | "rating": 16619, 103 | "song_level": { 104 | "title": "[NWAD]", 105 | "version": "1.0.0", 106 | "b15": false, 107 | "song_id": 15, 108 | "song_level_id": 45, 109 | "difficulty_id": 3, 110 | "difficulty": "Massive", 111 | "level": 16.2, 112 | "cover": "[NWAD].png", 113 | "fitting_level": null 114 | } 115 | }, 116 | { 117 | "play_record_id": 15, 118 | "username": "icelocke", 119 | "record_time": "2024-04-08T22:57:59.890554", 120 | "score": 1007670, 121 | "rating": 16611, 122 | "song_level": { 123 | "title": "Rondo of the 3ND", 124 | "version": "1.0.0", 125 | "b15": false, 126 | "song_id": 9, 127 | "song_level_id": 27, 128 | "difficulty_id": 3, 129 | "difficulty": "Massive", 130 | "level": 16.1, 131 | "cover": "Rondo of the 3ND.png", 132 | "fitting_level": null 133 | } 134 | }, 135 | { 136 | "play_record_id": 19, 137 | "username": "icelocke", 138 | "record_time": "2024-04-08T22:58:56.539914", 139 | "score": 1008899, 140 | "rating": 16593, 141 | "song_level": { 142 | "title": "Equality of Equites", 143 | "version": "1.2.0", 144 | "b15": false, 145 | "song_id": 62, 146 | "song_level_id": 186, 147 | "difficulty_id": 3, 148 | "difficulty": "Massive", 149 | "level": 16, 150 | "cover": "Equality of Equites.png", 151 | "fitting_level": null 152 | } 153 | }, 154 | { 155 | "play_record_id": 6, 156 | "username": "icelocke", 157 | "record_time": "2024-04-08T22:54:37.205368", 158 | "score": 997857, 159 | "rating": 16546, 160 | "song_level": { 161 | "title": "Rrhar'il", 162 | "version": "1.4.1", 163 | "b15": false, 164 | "song_id": 74, 165 | "song_level_id": 222, 166 | "difficulty_id": 3, 167 | "difficulty": "Massive", 168 | "level": 16.7, 169 | "cover": "Rrhar'il.png", 170 | "fitting_level": null 171 | } 172 | }, 173 | { 174 | "play_record_id": 10, 175 | "username": "icelocke", 176 | "record_time": "2024-04-08T22:56:16.615007", 177 | "score": 1007774, 178 | "rating": 16518, 179 | "song_level": { 180 | "title": "夏色の思い出", 181 | "version": "1.7.0", 182 | "b15": false, 183 | "song_id": 46, 184 | "song_level_id": 138, 185 | "difficulty_id": 3, 186 | "difficulty": "Massive", 187 | "level": 16, 188 | "cover": "夏色の思い出.png", 189 | "fitting_level": null 190 | } 191 | }, 192 | { 193 | "play_record_id": 13, 194 | "username": "icelocke", 195 | "record_time": "2024-04-08T22:56:48.997818", 196 | "score": 1005532, 197 | "rating": 16468, 198 | "song_level": { 199 | "title": "キミとボクへの葬送歌", 200 | "version": "1.0.0", 201 | "b15": false, 202 | "song_id": 129, 203 | "song_level_id": 387, 204 | "difficulty_id": 3, 205 | "difficulty": "Massive", 206 | "level": 16.1, 207 | "cover": "キミとボクへの葬送歌.png", 208 | "fitting_level": null 209 | } 210 | }, 211 | { 212 | "play_record_id": 38, 213 | "username": "icelocke", 214 | "record_time": "2024-04-14T21:07:03.920926", 215 | "score": 1009500, 216 | "rating": 16430, 217 | "song_level": { 218 | "title": "Verreta", 219 | "version": "1.0.0", 220 | "b15": false, 221 | "song_id": 8, 222 | "song_level_id": 24, 223 | "difficulty_id": 3, 224 | "difficulty": "Massive", 225 | "level": 15.8, 226 | "cover": "Verreta.png", 227 | "fitting_level": null 228 | } 229 | }, 230 | { 231 | "play_record_id": 36, 232 | "username": "icelocke", 233 | "record_time": "2024-04-14T21:07:03.897344", 234 | "score": 1007200, 235 | "rating": 16380, 236 | "song_level": { 237 | "title": "Ashes", 238 | "version": "1.8.0", 239 | "b15": false, 240 | "song_id": 37, 241 | "song_level_id": 111, 242 | "difficulty_id": 3, 243 | "difficulty": "Massive", 244 | "level": 15.9, 245 | "cover": "Ashes.png", 246 | "fitting_level": null 247 | } 248 | }, 249 | { 250 | "play_record_id": 37, 251 | "username": "icelocke", 252 | "record_time": "2024-04-14T21:07:03.909289", 253 | "score": 1008600, 254 | "rating": 16370, 255 | "song_level": { 256 | "title": "Fracture Temporelle", 257 | "version": "1.9.2", 258 | "b15": false, 259 | "song_id": 113, 260 | "song_level_id": 339, 261 | "difficulty_id": 3, 262 | "difficulty": "Massive", 263 | "level": 15.8, 264 | "cover": "Fracture Temporelle.png", 265 | "fitting_level": null 266 | } 267 | }, 268 | { 269 | "play_record_id": 40, 270 | "username": "icelocke", 271 | "record_time": "2024-04-14T21:07:03.938416", 272 | "score": 1009600, 273 | "rating": 16340, 274 | "song_level": { 275 | "title": "Unpredictable Nascent Invasion", 276 | "version": "1.9.3", 277 | "b15": false, 278 | "song_id": 114, 279 | "song_level_id": 342, 280 | "difficulty_id": 3, 281 | "difficulty": "Massive", 282 | "level": 15.7, 283 | "cover": "Unpredictable Nascent Invasion.png", 284 | "fitting_level": null 285 | } 286 | }, 287 | { 288 | "play_record_id": 42, 289 | "username": "icelocke", 290 | "record_time": "2024-04-14T21:07:03.959654", 291 | "score": 1009500, 292 | "rating": 16230, 293 | "song_level": { 294 | "title": "Subterranean Blastoff", 295 | "version": "1.0.0", 296 | "b15": false, 297 | "song_id": 98, 298 | "song_level_id": 294, 299 | "difficulty_id": 3, 300 | "difficulty": "Massive", 301 | "level": 15.6, 302 | "cover": "Subterranean Blastoff.png", 303 | "fitting_level": null 304 | } 305 | }, 306 | { 307 | "play_record_id": 45, 308 | "username": "icelocke", 309 | "record_time": "2024-04-14T21:07:03.989469", 310 | "score": 1009600, 311 | "rating": 16230, 312 | "song_level": { 313 | "title": "[PRAW]", 314 | "version": "1.0.0", 315 | "b15": false, 316 | "song_id": 14, 317 | "song_level_id": 42, 318 | "difficulty_id": 3, 319 | "difficulty": "Massive", 320 | "level": 15.6, 321 | "cover": "[PRAW].png", 322 | "fitting_level": null 323 | } 324 | }, 325 | { 326 | "play_record_id": 43, 327 | "username": "icelocke", 328 | "record_time": "2024-04-14T21:07:03.970474", 329 | "score": 1009100, 330 | "rating": 16200, 331 | "song_level": { 332 | "title": "Black:Magnam", 333 | "version": "1.3.0", 334 | "b15": false, 335 | "song_id": 73, 336 | "song_level_id": 219, 337 | "difficulty_id": 3, 338 | "difficulty": "Massive", 339 | "level": 15.6, 340 | "cover": "Black:Magnam.png", 341 | "fitting_level": null 342 | } 343 | }, 344 | { 345 | "play_record_id": 41, 346 | "username": "icelocke", 347 | "record_time": "2024-04-14T21:07:03.949177", 348 | "score": 1008700, 349 | "rating": 16180, 350 | "song_level": { 351 | "title": "felys -final remix-", 352 | "version": "1.9.0", 353 | "b15": false, 354 | "song_id": 111, 355 | "song_level_id": 333, 356 | "difficulty_id": 3, 357 | "difficulty": "Massive", 358 | "level": 15.6, 359 | "cover": "felys -final remix-.png", 360 | "fitting_level": null 361 | } 362 | }, 363 | { 364 | "play_record_id": 44, 365 | "username": "icelocke", 366 | "record_time": "2024-04-14T21:07:03.979565", 367 | "score": 1008800, 368 | "rating": 16180, 369 | "song_level": { 370 | "title": "REDRAVE", 371 | "version": "1.0.0", 372 | "b15": false, 373 | "song_id": 53, 374 | "song_level_id": 159, 375 | "difficulty_id": 3, 376 | "difficulty": "Massive", 377 | "level": 15.6, 378 | "cover": "REDRAVE.png", 379 | "fitting_level": null 380 | } 381 | }, 382 | { 383 | "play_record_id": 35, 384 | "username": "icelocke", 385 | "record_time": "2024-04-14T21:07:03.885357", 386 | "score": 1003000, 387 | "rating": 16100, 388 | "song_level": { 389 | "title": "Destr0yer", 390 | "version": "1.6.0", 391 | "b15": false, 392 | "song_id": 137, 393 | "song_level_id": 411, 394 | "difficulty_id": 3, 395 | "difficulty": "Massive", 396 | "level": 15.9, 397 | "cover": "Destr0yer.png", 398 | "fitting_level": null 399 | } 400 | }, 401 | { 402 | "play_record_id": 47, 403 | "username": "icelocke", 404 | "record_time": "2024-04-14T21:07:04.011113", 405 | "score": 1008900, 406 | "rating": 16090, 407 | "song_level": { 408 | "title": "Yokyuumi", 409 | "version": "1.5.0", 410 | "b15": false, 411 | "song_id": 49, 412 | "song_level_id": 147, 413 | "difficulty_id": 3, 414 | "difficulty": "Massive", 415 | "level": 15.5, 416 | "cover": "Yokyuumi.png", 417 | "fitting_level": null 418 | } 419 | }, 420 | { 421 | "play_record_id": 46, 422 | "username": "icelocke", 423 | "record_time": "2024-04-14T21:07:04.000186", 424 | "score": 1008800, 425 | "rating": 16080, 426 | "song_level": { 427 | "title": "BPM=RT", 428 | "version": "1.4.1", 429 | "b15": false, 430 | "song_id": 78, 431 | "song_level_id": 234, 432 | "difficulty_id": 3, 433 | "difficulty": "Massive", 434 | "level": 15.5, 435 | "cover": "BPM=RT.png", 436 | "fitting_level": null 437 | } 438 | }, 439 | { 440 | "play_record_id": 48, 441 | "username": "icelocke", 442 | "record_time": "2024-04-14T21:07:04.020783", 443 | "score": 1008800, 444 | "rating": 16080, 445 | "song_level": { 446 | "title": "Forgotten Asteroid", 447 | "version": "1.0.0", 448 | "b15": false, 449 | "song_id": 10, 450 | "song_level_id": 30, 451 | "difficulty_id": 3, 452 | "difficulty": "Massive", 453 | "level": 15.5, 454 | "cover": "Forgotten Asteroid.png", 455 | "fitting_level": null 456 | } 457 | }, 458 | { 459 | "play_record_id": 50, 460 | "username": "icelocke", 461 | "record_time": "2024-04-14T21:07:04.042759", 462 | "score": 1009700, 463 | "rating": 16040, 464 | "song_level": { 465 | "title": "Marsh Winds", 466 | "version": "1.0.0", 467 | "b15": false, 468 | "song_id": 52, 469 | "song_level_id": 156, 470 | "difficulty_id": 3, 471 | "difficulty": "Massive", 472 | "level": 15.4, 473 | "cover": "Marsh Winds.png", 474 | "fitting_level": null 475 | } 476 | }, 477 | { 478 | "play_record_id": 52, 479 | "username": "icelocke", 480 | "record_time": "2024-04-14T21:07:04.064296", 481 | "score": 1009700, 482 | "rating": 15940, 483 | "song_level": { 484 | "title": "零號車輛", 485 | "version": "1.0.0", 486 | "b15": false, 487 | "song_id": 12, 488 | "song_level_id": 36, 489 | "difficulty_id": 3, 490 | "difficulty": "Massive", 491 | "level": 15.3, 492 | "cover": "零號車輛.png", 493 | "fitting_level": null 494 | } 495 | }, 496 | { 497 | "play_record_id": 51, 498 | "username": "icelocke", 499 | "record_time": "2024-04-14T21:07:04.054308", 500 | "score": 1009500, 501 | "rating": 15930, 502 | "song_level": { 503 | "title": "Rebooted Mind", 504 | "version": "1.5.0", 505 | "b15": false, 506 | "song_id": 48, 507 | "song_level_id": 144, 508 | "difficulty_id": 3, 509 | "difficulty": "Massive", 510 | "level": 15.3, 511 | "cover": "Rebooted Mind.png", 512 | "fitting_level": null 513 | } 514 | }, 515 | { 516 | "play_record_id": 62, 517 | "username": "icelocke", 518 | "record_time": "2024-04-14T21:14:29.665332", 519 | "score": 1009900, 520 | "rating": 15860, 521 | "song_level": { 522 | "title": "REDASH", 523 | "version": "1.9.0", 524 | "b15": false, 525 | "song_id": 80, 526 | "song_level_id": 240, 527 | "difficulty_id": 3, 528 | "difficulty": "Massive", 529 | "level": 15.2, 530 | "cover": "REDASH.png", 531 | "fitting_level": null 532 | } 533 | }, 534 | { 535 | "play_record_id": 49, 536 | "username": "icelocke", 537 | "record_time": "2024-04-14T21:07:04.030742", 538 | "score": 1006600, 539 | "rating": 15840, 540 | "song_level": { 541 | "title": "macro.wav", 542 | "version": "1.4.1", 543 | "b15": false, 544 | "song_id": 65, 545 | "song_level_id": 195, 546 | "difficulty_id": 3, 547 | "difficulty": "Massive", 548 | "level": 15.4, 549 | "cover": "macro.wav.png", 550 | "fitting_level": null 551 | } 552 | }, 553 | { 554 | "play_record_id": 53, 555 | "username": "icelocke", 556 | "record_time": "2024-04-14T21:07:04.074381", 557 | "score": 1009700, 558 | "rating": 15840, 559 | "song_level": { 560 | "title": "c.s.q.n.", 561 | "version": "1.6.0", 562 | "b15": false, 563 | "song_id": 138, 564 | "song_level_id": 414, 565 | "difficulty_id": 3, 566 | "difficulty": "Massive", 567 | "level": 15.2, 568 | "cover": "c.s.q.n..png", 569 | "fitting_level": null 570 | } 571 | }, 572 | { 573 | "play_record_id": 61, 574 | "username": "icelocke", 575 | "record_time": "2024-04-14T21:14:29.657764", 576 | "score": 1009500, 577 | "rating": 15733, 578 | "song_level": { 579 | "title": "Chronostasis", 580 | "version": "1.0.0", 581 | "b15": false, 582 | "song_id": 135, 583 | "song_level_id": 405, 584 | "difficulty_id": 3, 585 | "difficulty": "Massive", 586 | "level": 15.1, 587 | "cover": "Chronostasis.png", 588 | "fitting_level": null 589 | } 590 | }, 591 | { 592 | "play_record_id": 60, 593 | "username": "icelocke", 594 | "record_time": "2024-04-14T21:14:29.633908", 595 | "score": 1008700, 596 | "rating": 15680, 597 | "song_level": { 598 | "title": "Cross†Ray", 599 | "version": "1.6.0", 600 | "b15": false, 601 | "song_id": 130, 602 | "song_level_id": 390, 603 | "difficulty_id": 3, 604 | "difficulty": "Massive", 605 | "level": 15.1, 606 | "cover": "Cross†Ray.png", 607 | "fitting_level": null 608 | } 609 | }, 610 | { 611 | "play_record_id": 63, 612 | "username": "icelocke", 613 | "record_time": "2024-04-14T21:14:29.672780", 614 | "score": 1007500, 615 | "rating": 15500, 616 | "song_level": { 617 | "title": "Xenolith", 618 | "version": "1.4.2", 619 | "b15": false, 620 | "song_id": 106, 621 | "song_level_id": 318, 622 | "difficulty_id": 3, 623 | "difficulty": "Massive", 624 | "level": 15, 625 | "cover": "Xenolith.png", 626 | "fitting_level": null 627 | } 628 | }, 629 | { 630 | "play_record_id": 65, 631 | "username": "icelocke", 632 | "record_time": "2024-04-14T21:14:29.689242", 633 | "score": 1007700, 634 | "rating": 15413, 635 | "song_level": { 636 | "title": "Protection", 637 | "version": "1.0.0", 638 | "b15": false, 639 | "song_id": 56, 640 | "song_level_id": 168, 641 | "difficulty_id": 3, 642 | "difficulty": "Massive", 643 | "level": 14.9, 644 | "cover": "Protection.png", 645 | "fitting_level": null 646 | } 647 | }, 648 | { 649 | "play_record_id": 64, 650 | "username": "icelocke", 651 | "record_time": "2024-04-14T21:14:29.681246", 652 | "score": 1006800, 653 | "rating": 15353, 654 | "song_level": { 655 | "title": "IKAROS", 656 | "version": "1.0.0", 657 | "b15": false, 658 | "song_id": 102, 659 | "song_level_id": 306, 660 | "difficulty_id": 3, 661 | "difficulty": "Massive", 662 | "level": 14.9, 663 | "cover": "IKAROS.png", 664 | "fitting_level": null 665 | } 666 | }, 667 | { 668 | "play_record_id": 12, 669 | "username": "icelocke", 670 | "record_time": "2024-04-08T22:56:37.540358", 671 | "score": 1008135, 672 | "rating": 17042, 673 | "song_level": { 674 | "title": "クリムゾン帝王", 675 | "version": "2.2.0", 676 | "b15": true, 677 | "song_id": 34, 678 | "song_level_id": 102, 679 | "difficulty_id": 3, 680 | "difficulty": "Massive", 681 | "level": 16.5, 682 | "cover": "クリムゾン帝王.png", 683 | "fitting_level": null 684 | } 685 | }, 686 | { 687 | "play_record_id": 32, 688 | "username": "icelocke", 689 | "record_time": "2024-04-12T16:25:23.394337", 690 | "score": 1008200, 691 | "rating": 16946, 692 | "song_level": { 693 | "title": "Hemisphere", 694 | "version": "2.1.0", 695 | "b15": true, 696 | "song_id": 42, 697 | "song_level_id": 126, 698 | "difficulty_id": 3, 699 | "difficulty": "Massive", 700 | "level": 16.4, 701 | "cover": "Hemisphere.png", 702 | "fitting_level": null 703 | } 704 | }, 705 | { 706 | "play_record_id": 33, 707 | "username": "icelocke", 708 | "record_time": "2024-04-12T16:25:23.407334", 709 | "score": 1007100, 710 | "rating": 16873, 711 | "song_level": { 712 | "title": "天使光輪", 713 | "version": "2.2.0", 714 | "b15": true, 715 | "song_id": 30, 716 | "song_level_id": 90, 717 | "difficulty_id": 3, 718 | "difficulty": "Massive", 719 | "level": 16.4, 720 | "cover": "天使光輪.png", 721 | "fitting_level": null 722 | } 723 | }, 724 | { 725 | "play_record_id": 20, 726 | "username": "icelocke", 727 | "record_time": "2024-04-08T22:59:11.294794", 728 | "score": 1008467, 729 | "rating": 16864, 730 | "song_level": { 731 | "title": "Avantgarde", 732 | "version": "2.2.0", 733 | "b15": true, 734 | "song_id": 89, 735 | "song_level_id": 267, 736 | "difficulty_id": 3, 737 | "difficulty": "Massive", 738 | "level": 16.3, 739 | "cover": "Avantgarde.png", 740 | "fitting_level": null 741 | } 742 | }, 743 | { 744 | "play_record_id": 8, 745 | "username": "icelocke", 746 | "record_time": "2024-04-08T22:55:18.390230", 747 | "score": 1008636, 748 | "rating": 16775, 749 | "song_level": { 750 | "title": "桜花怨雷", 751 | "version": "2.3.0", 752 | "b15": true, 753 | "song_id": 26, 754 | "song_level_id": 78, 755 | "difficulty_id": 3, 756 | "difficulty": "Massive", 757 | "level": 16.2, 758 | "cover": "桜花怨雷.png", 759 | "fitting_level": null 760 | } 761 | }, 762 | { 763 | "play_record_id": 66, 764 | "username": "icelocke", 765 | "record_time": "2024-04-14T21:15:43.357054", 766 | "score": 1008000, 767 | "rating": 16433, 768 | "song_level": { 769 | "title": "ARTEMiS", 770 | "version": "2.2.0", 771 | "b15": true, 772 | "song_id": 29, 773 | "song_level_id": 87, 774 | "difficulty_id": 3, 775 | "difficulty": "Massive", 776 | "level": 15.9, 777 | "cover": "ARTEMiS.png", 778 | "fitting_level": null 779 | } 780 | }, 781 | { 782 | "play_record_id": 22, 783 | "username": "icelocke", 784 | "record_time": "2024-04-12T15:14:01.713222", 785 | "score": 1008700, 786 | "rating": 16379, 787 | "song_level": { 788 | "title": "Echoes of the Forest", 789 | "version": "2.0.0", 790 | "b15": true, 791 | "song_id": 21, 792 | "song_level_id": 63, 793 | "difficulty_id": 3, 794 | "difficulty": "Massive", 795 | "level": 15.8, 796 | "cover": "Echoes of the Forest.png", 797 | "fitting_level": null 798 | } 799 | }, 800 | { 801 | "play_record_id": 68, 802 | "username": "icelocke", 803 | "record_time": "2024-04-14T21:15:43.370525", 804 | "score": 1009500, 805 | "rating": 16333, 806 | "song_level": { 807 | "title": "Alexandrite", 808 | "version": "2.3.1", 809 | "b15": true, 810 | "song_id": 121, 811 | "song_level_id": 363, 812 | "difficulty_id": 3, 813 | "difficulty": "Massive", 814 | "level": 15.7, 815 | "cover": "Alexandrite.png", 816 | "fitting_level": null 817 | } 818 | }, 819 | { 820 | "play_record_id": 69, 821 | "username": "icelocke", 822 | "record_time": "2024-04-14T21:15:43.378598", 823 | "score": 1008000, 824 | "rating": 16233, 825 | "song_level": { 826 | "title": "RE; Boot", 827 | "version": "2.0.0", 828 | "b15": true, 829 | "song_id": 115, 830 | "song_level_id": 345, 831 | "difficulty_id": 3, 832 | "difficulty": "Massive", 833 | "level": 15.7, 834 | "cover": "RE; Boot.png", 835 | "fitting_level": null 836 | } 837 | }, 838 | { 839 | "play_record_id": 70, 840 | "username": "icelocke", 841 | "record_time": "2024-04-14T21:15:43.386105", 842 | "score": 1008000, 843 | "rating": 15933, 844 | "song_level": { 845 | "title": "The Grand F-STAR", 846 | "version": "2.0.0", 847 | "b15": true, 848 | "song_id": 20, 849 | "song_level_id": 60, 850 | "difficulty_id": 3, 851 | "difficulty": "Massive", 852 | "level": 15.4, 853 | "cover": "The Grand F-STAR.png", 854 | "fitting_level": null 855 | } 856 | }, 857 | { 858 | "play_record_id": 71, 859 | "username": "icelocke", 860 | "record_time": "2024-04-14T21:15:43.393122", 861 | "score": 1009500, 862 | "rating": 15833, 863 | "song_level": { 864 | "title": "conflict", 865 | "version": "2.2.0", 866 | "b15": true, 867 | "song_id": 87, 868 | "song_level_id": 261, 869 | "difficulty_id": 3, 870 | "difficulty": "Massive", 871 | "level": 15.2, 872 | "cover": "conflict.png", 873 | "fitting_level": null 874 | } 875 | }, 876 | { 877 | "play_record_id": 73, 878 | "username": "icelocke", 879 | "record_time": "2024-04-14T21:15:43.408199", 880 | "score": 1009590, 881 | "rating": 15739, 882 | "song_level": { 883 | "title": "WORLDCALL", 884 | "version": "2.2.0", 885 | "b15": true, 886 | "song_id": 91, 887 | "song_level_id": 273, 888 | "difficulty_id": 3, 889 | "difficulty": "Massive", 890 | "level": 15.1, 891 | "cover": "WORLDCALL.png", 892 | "fitting_level": null 893 | } 894 | }, 895 | { 896 | "play_record_id": 72, 897 | "username": "icelocke", 898 | "record_time": "2024-04-14T21:15:43.400569", 899 | "score": 1008000, 900 | "rating": 15733, 901 | "song_level": { 902 | "title": "Chariot", 903 | "version": "2.2.0", 904 | "b15": true, 905 | "song_id": 32, 906 | "song_level_id": 96, 907 | "difficulty_id": 3, 908 | "difficulty": "Massive", 909 | "level": 15.2, 910 | "cover": "Chariot.png", 911 | "fitting_level": null 912 | } 913 | }, 914 | { 915 | "play_record_id": 7, 916 | "username": "icelocke", 917 | "record_time": "2024-04-08T22:54:47.337501", 918 | "score": 976153, 919 | "rating": 15709, 920 | "song_level": { 921 | "title": "Cybernetic Vampire", 922 | "version": "2.0.0", 923 | "b15": true, 924 | "song_id": 22, 925 | "song_level_id": 66, 926 | "difficulty_id": 3, 927 | "difficulty": "Massive", 928 | "level": 16.6, 929 | "cover": "Cybernetic Vampire.png", 930 | "fitting_level": null 931 | } 932 | }, 933 | { 934 | "play_record_id": 74, 935 | "username": "icelocke", 936 | "record_time": "2024-04-14T21:16:12.991969", 937 | "score": 1009600, 938 | "rating": 15040, 939 | "song_level": { 940 | "title": "Paradigm Shift", 941 | "version": "2.0.0", 942 | "b15": true, 943 | "song_id": 84, 944 | "song_level_id": 252, 945 | "difficulty_id": 3, 946 | "difficulty": "Massive", 947 | "level": 14.4, 948 | "cover": "Paradigm Shift.png", 949 | "fitting_level": null 950 | } 951 | } 952 | ] -------------------------------------------------------------------------------- /unit_test/csv/gen_empty.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from pathlib import Path 4 | 5 | import backend.prprober.util.b50.csv 6 | 7 | 8 | def test_something(): 9 | with open('./data.json', 'r', encoding='utf-8') as f: 10 | data = json.load(f) 11 | levels = sorted(data, key=lambda x: x['level'], reverse=True) 12 | backend.prprober.util.b50.csv.generate_empty_csv(Path(__file__).parent, levels) 13 | 14 | 15 | if __name__ == '__main__': 16 | test_something() 17 | -------------------------------------------------------------------------------- /unit_test/csv/read_csv.py: -------------------------------------------------------------------------------- 1 | import backend.prprober.util.b50.csv 2 | 3 | 4 | def test_something(): 5 | print(backend.prprober.util.b50.csv.get_records_from_csv()) 6 | 7 | 8 | if __name__ == '__main__': 9 | test_something() 10 | -------------------------------------------------------------------------------- /unit_test/ocr/ocr_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | 4 | import backend.prprober.util.ocr as ocr 5 | 6 | 7 | class OCRSimpleTestCase(unittest.TestCase): 8 | song_titles: list[str] = ['桜花怨雷', 'CO5M1C R4ILR0AD', 'REDRAVE', 9 | 'キミとボクへの葬送歌', '今天不是明天 (feat. 兰音Reine)', 'クリムゾン帝王'] 10 | difficulties: list[str] = ['DETECTED', 'INVADED', 'MASSIVE'] 11 | score: dict[str, int] = { 12 | '桜花怨雷': 997307, 13 | '今天不是明天 (feat. 兰音Reine)': 1009576, 14 | 'CO5M1C R4ILR0AD': 1007983, 15 | 'REDRAVE': 1008528, 16 | 'キミとボクへの葬送歌': 1005532, 17 | 'クリムゾン帝王': 1005140 18 | } 19 | test_img_root: str = 'ocr/test_img/' 20 | 21 | def test_something(self): 22 | images = os.listdir(OCRSimpleTestCase.test_img_root) 23 | for img in images: 24 | result = ocr.extract_record_info(OCRSimpleTestCase.test_img_root + img, 25 | song_titles=OCRSimpleTestCase.song_titles, 26 | difficulties=OCRSimpleTestCase.difficulties) 27 | print(result) 28 | self.assertEqual(OCRSimpleTestCase.score[result['title'][0]], result['score']) 29 | 30 | 31 | if __name__ == '__main__': 32 | unittest.main() 33 | -------------------------------------------------------------------------------- /unit_test/ocr/test_img/CO5M1C_R4ILROAD.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/unit_test/ocr/test_img/CO5M1C_R4ILROAD.PNG -------------------------------------------------------------------------------- /unit_test/ocr/test_img/REDRAVE.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/unit_test/ocr/test_img/REDRAVE.PNG -------------------------------------------------------------------------------- /unit_test/ocr/test_img/今天不是明天.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/unit_test/ocr/test_img/今天不是明天.png -------------------------------------------------------------------------------- /unit_test/ocr/test_img/帝王.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/unit_test/ocr/test_img/帝王.jpg -------------------------------------------------------------------------------- /unit_test/ocr/test_img/樱花怨雷.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/unit_test/ocr/test_img/樱花怨雷.PNG -------------------------------------------------------------------------------- /unit_test/ocr/test_img/葬送歌.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PRProber/paradigm-reboot-prober-backend/ebed9a70011324855b943b4adeead8e7ad220a01/unit_test/ocr/test_img/葬送歌.PNG -------------------------------------------------------------------------------- /unit_test/rating/rating_sum_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import csv 3 | from backend.prprober.util import single_rating 4 | 5 | 6 | class MyTestCase(unittest.TestCase): 7 | @staticmethod 8 | def test_something(): 9 | with open('rating/data.csv', 'r', encoding='utf-8') as f: 10 | reader = csv.DictReader(f) 11 | for record in reader: 12 | rt = single_rating(float(record['level']), int(record['score'])) 13 | if rt != int(record['rating']): 14 | print(f"diff level={record['level']}, score={record['score']}, gt_rating={record['rating']}, rating={rt}") 15 | 16 | 17 | if __name__ == '__main__': 18 | unittest.main() 19 | --------------------------------------------------------------------------------