This commit is contained in:
UnitedAirforce
2025-08-16 15:38:59 +08:00
parent f05520b958
commit 77b7e0ba35
6 changed files with 98 additions and 9 deletions
+10 -2
View File
@@ -33,10 +33,10 @@ Warning: Do not put personal files under the folders in the private server direc
| Ranking | Individual song-difficulty ranking. Support total score ranking, but does not support regional ranking. Does not support viewing player profile. |
| Save backup | Support save/load via an Account system. Support password and username changes. Support logging out. |
| Titles | Static full-unlock and setting titles via "Status". |
| Mission | Basic automatic song unlock after reaching in-game levels. Everything else is not supported. |
| Mission | Basic automatic song unlock after reaching in-game levels. Everything else is not supported. |
| Friend | Not supported. |
| Progress Grid | Not supported. |
| Additional features | Account/device whitelisting and banning. |
| Additional features | Account/device whitelisting and banning, batch download API |
## Download
@@ -267,6 +267,10 @@ A rather comprehensive data scrape was conducted prior to the server shutdown, c
Note that this data is for analytics only, and the functionality to embed this data inside the private server is not and will not be supported by me. Feel free to Fork and create your own implementation.
## Asset Batch Downloading
Since this game features tons of downloadable music and stage files that cannot be natively acquired, a `flutter` app has been programmed to download all the files using a server API endpoint. the package can be resigned to have the same app id and signature, thus allowing overwrite installation with the game. It supports both Android and iOS. Development is still ongoing about the permission/authorization side of things, stay tuned...
</details>
<details>
@@ -527,6 +531,10 @@ PC用文本编辑器打开服务器文件夹的 `config.env`,将`IPV4`填写
请注意,此数据仅用于分析,私服内置不会被实现。如果有需求,请Fork然后自行设计。
## 资源批量下载
由于这款游戏包含大量无法通过程序自身自动获取的可下载音乐和谱面文件,因此已开发了一个 `flutter` 应用程序,通过服务器 API 接口下载所有文件。该包可重新签名以使用相同的应用程序 ID 和签名,从而实现与游戏的覆盖安装。该应用支持 Android 和 iOS 系统。目前仍在开发权限/授权相关功能,敬请期待...
</details>
<details>
+47
View File
@@ -0,0 +1,47 @@
from starlette.responses import HTMLResponse
from starlette.requests import Request
from starlette.routing import Route
import os
import json
import time
from api.database import database, batch_token
from config import THREAD_COUNT
async def batch_handler(request: Request):
data = await request.json()
token = data.get("token")
platform = data.get("platform")
if not token:
return HTMLResponse(content=json.dumps({"error": "Token is required"}), status_code=400)
if platform not in ["Android", "iOS"]:
return HTMLResponse(content=json.dumps({"error": "Invalid platform"}), status_code=400)
query = batch_token.select().where(batch_token.c.token == token)
result = await database.fetch_one(query)
if result['expire_at'] < int(time.time()):
return HTMLResponse(content=json.dumps({"error": "Token expired"}), status_code=400)
with open(os.path.join('api/config/', 'download_manifest.json'), 'r', encoding='utf-8') as f:
stage_manifest = json.load(f)
if platform == "Android":
with open(os.path.join('api/config/', 'download_manifest_android.json'), 'r', encoding='utf-8') as f:
audio_manifest = json.load(f)
else:
with open(os.path.join('api/config/', 'download_manifest_ios.json'), 'r', encoding='utf-8') as f:
audio_manifest = json.load(f)
download_manifest = {
"stage": stage_manifest,
"audio": audio_manifest,
"thread": THREAD_COUNT
}
return HTMLResponse(content=json.dumps(download_manifest), status_code=200)
routes = [
Route("/batch", batch_handler, methods=["POST"]),
]
+16 -6
View File
@@ -8,6 +8,8 @@ from config import REDIS_ADDRESS, USE_REDIS_CACHE
import os
import databases
import datetime
import time
if USE_REDIS_CACHE:
import redis.asyncio as aioredis
@@ -83,6 +85,17 @@ blacklist = Table(
Column("reason", String(256))
)
batch_token = Table(
"batch_token",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("token", String(512), nullable=False, unique=True),
Column("sid", Integer, nullable=False),
Column("verification_name", String(64), nullable=False, default=False),
Column("verification_id", String(64), nullable=False),
Column("expire_at", Integer, default=int(time.time()) + 1800),
)
async def init_db():
global redis
if not os.path.exists(DB_PATH):
@@ -102,8 +115,7 @@ async def init_db():
async def get_user_data(uid, data_field):
query = select(user.c[data_field]).where(user.c.device_id == uid[b'vid'][0].decode())
async with database.transaction():
result = await database.fetch_one(query)
result = await database.fetch_one(query)
return result[data_field] if result else None
async def set_user_data(uid, data_field, new_data):
@@ -117,8 +129,7 @@ async def set_user_data(uid, data_field, new_data):
async def check_whitelist(uid):
query = select(whitelist.c.id).where(whitelist.c.id == uid[b'vid'][0].decode())
async with database.transaction():
result = await database.fetch_one(query)
result = await database.fetch_one(query)
return result is not None
async def check_blacklist(uid):
@@ -129,8 +140,7 @@ async def check_blacklist(uid):
query = select(blacklist.c.id).where(
(blacklist.c.id == device_id) | (blacklist.c.id == username)
)
async with database.transaction():
result = await database.fetch_one(query)
result = await database.fetch_one(query)
return result is None
async def ensure_user_columns():
+1 -1
View File
@@ -34,7 +34,7 @@ TITLE_LISTS = {
def init_templates():
global SONG_LIST, AVATAR_LIST, ITEM_LIST, EXP_UNLOCKED_SONGS
base_path = 'api/config/'
base_path = 'api/config/'
print("[TEMPLATES] Initializing templates...")
try:
@@ -0,0 +1,21 @@
import os
import json
base_dir = os.getcwd()
stage_folder = os.path.join(base_dir, "stage")
stage_files = [f for f in os.listdir(stage_folder) if os.path.isfile(os.path.join(stage_folder, f))]
with open("download_manifest.json", "w", encoding="utf-8") as f:
json.dump(stage_files, f, ensure_ascii=False, indent=2)
audio_folder = os.path.join(base_dir, "audio")
ogg_zip_files = [f for f in os.listdir(audio_folder) if f.endswith(".ogg.zip") and os.path.isfile(os.path.join(audio_folder, f))]
with open("download_manifest_android.json", "w", encoding="utf-8") as f:
json.dump(ogg_zip_files, f, ensure_ascii=False, indent=2)
m4a_zip_files = [f for f in os.listdir(audio_folder) if f.endswith(".m4a.zip") and os.path.isfile(os.path.join(audio_folder, f))]
with open("download_manifest_ios.json", "w", encoding="utf-8") as f:
json.dump(m4a_zip_files, f, ensure_ascii=False, indent=2)
@@ -0,0 +1,3 @@
Since flutter cannot do native zipcrypto and beautifulSoup shenanigans, I opted to pre-compute the list of files that the downloader should take.
Place this script with the `pak` files and run. It will generate 3 json files, one for `common` (stages), one for `android` (ogg), and one for `ios` (m4a). The flutter app will simply query the endpoint and use the list to download.