├── .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 |
--------------------------------------------------------------------------------