13 Commits

Author SHA1 Message Date
4895990729 fix: Fix leaderboard freeze and AJAX submission issues - Fixed fetchData async/await to use proper Promise pattern - Added basedir prefix to API URLs - Fixed submitToLeaderboard to use XMLHttpRequest with CSRF token 2026-01-18 07:55:52 +08:00
6686f5ae15 feat: Add song difficulty leaderboard feature 2026-01-18 07:42:58 +08:00
8d58bf683f Optimize loading with multi-threaded worker loader 2026-01-03 16:10:27 +08:00
taiko-web
6d7be5c45c initial import 2026-01-01 21:31:54 +08:00
92c1261f6f feat: 中文曲名优先显示 TITLEZH,其次 TITLEJA;版本号改为 vLightNova 1.0.0 2025-12-14 12:49:20 +08:00
9a2a7dbee6 chore: push pending changes 2025-12-06 20:02:43 +08:00
f91e3c9089 feat: support TITLEJA/TITLE by language 2025-11-30 16:50:45 +08:00
84a0c2b7e0 fix(songselect): category jump fallback to song_type when category missing 2025-11-23 13:07:17 +08:00
9f2d753500 docs: add 全命令部署(Docker MongoDB) 2025-11-23 08:18:00 +08:00
a77534c72b feat(type): 新增歌曲类型模型与校验;/api/songs 支持按类型过滤;上传页增加类型选择;歌曲选择页支持左右切换类型并显示标签;README 补充说明 2025-11-22 23:25:10 +08:00
1ca7a3f610 fix(upload): DuplicateKey 使用 更新,避免替换触发 _id 不可变错误 (code 66) 2025-11-22 22:33:50 +08:00
4da81d16dc fix(upload): 处理重复 ID,出现 DuplicateKey 时替换现有文档并启用 2025-11-22 22:29:50 +08:00
3d611a9c46 fix(upload): 使用绝对路径和父目录创建,避免 public/songs 不存在导致上传失败 2025-11-22 22:27:11 +08:00
19 changed files with 2244 additions and 1118 deletions

View File

@@ -0,0 +1,20 @@
I will optimize the loading process by implementing a **Multi-Threaded Worker Loader**. This involves creating a pool of Web Workers to fetch assets (JavaScript, Audio, Images, Views) in parallel, offloading the network initiation and handling from the main thread.
### Plan:
1. **Create `public/src/js/loader-worker.js`**:
* This worker will handle `fetch` requests for different resource types (`text`, `blob`, `arraybuffer`).
* It will transfer the data back to the main thread (using zero-copy transfer for `ArrayBuffer`).
2. **Modify `public/src/js/loader.js`**:
* **Initialize Worker Pool**: Create a pool of workers (defaulting to 4) in the `Loader` class.
* **Implement `workerFetch(url, type)`**: A method to distribute fetch tasks to the worker pool.
* **Override `ajax(url, ...)`**: Intercept requests for static assets (`src/`, `assets/`, etc.) and route them through `workerFetch`. Keep API calls (`api/`) on the main thread to ensure session stability.
* **Update `loadScript(url)`**: Change it to fetch the script content via `workerFetch` and inject it using a `<script>` tag with inline content. This ensures JS files are also loaded via the "multi-process" mechanism.
* **Update `loadSound` and `RemoteFile` logic**: Since `RemoteFile` uses `loader.ajax`, routing `ajax` to workers will automatically parallelize audio loading.
### Technical Details:
* **Concurrency**: 4 Workers will be used to maximize throughput without overloading the browser's connection limit per domain.
* **Resource Types**:
* **JS/Views**: Fetched as `text`.
* **Images**: Fetched as `blob` -> `URL.createObjectURL`.
* **Audio**: Fetched as `arraybuffer` -> `AudioContext.decodeAudioData`.

View File

@@ -0,0 +1,9 @@
I will implement the requested changes to remove the delete functionality and redirect the upload interface while keeping the upload API intact.
### 1. Frontend: Song Selection Menu (`public/src/js/songselect.js`)
* **Remove Delete Button**: I will remove the "Delete" (削除) button configuration from the `difficultyMenu` buttons array (around lines 313-319). This removes the option from the UI.
* **Redirect Upload Action**: I will modify the handler for the "upload" action (around lines 954-958). Instead of redirecting to the local `/upload/` page, it will redirect to `https://zizhipu.taiko.asia`.
### 2. Backend: API Security (`app.py`)
* **Disable Delete API**: I will modify the `/api/delete` route to return a 403 Forbidden error (or simply pass), ensuring that songs cannot be deleted even if someone calls the API directly.
* **Keep Upload API**: The `/api/upload` route will remain unchanged, preserving the ability to upload songs via API as requested.

View File

@@ -33,6 +33,55 @@ http://<服务器IP>/
- 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80`
- 开放防火墙 `80/tcp`(如系统启用了 `ufw`
## 全命令部署(使用 Docker 部署 MongoDB
适用系统Ubuntu 20.04+/22.04+/24.04(需 rootMongoDB 通过 Docker 启动,其余步骤照常。
1. 安装 Docker 并启动:
```bash
sudo apt update
sudo apt install -y docker.io
sudo systemctl enable --now docker
```
2. 启动 MongoDB 容器(持久化到 `/srv/taiko-web-mongo`,监听 `27017`
```bash
sudo mkdir -p /srv/taiko-web-mongo
sudo docker run -d \
--name taiko-web-mongo \
--restart unless-stopped \
-v /srv/taiko-web-mongo:/data/db \
-p 27017:27017 \
mongo:6
```
如需开启认证,可加上:
```bash
-e MONGO_INITDB_ROOT_USERNAME=<用户名> -e MONGO_INITDB_ROOT_PASSWORD=<强密码>
```
并在应用侧通过环境变量指定 Host
```bash
export TAIKO_WEB_MONGO_HOST=127.0.0.1:27017
```
3. 安装并启动 Redis照常
```bash
sudo apt install -y redis-server
sudo systemctl enable --now redis-server
```
4. 准备项目与虚拟环境(照常):
```bash
sudo mkdir -p /srv/taiko-web
sudo rsync -a --delete --exclude '.git' --exclude '.venv' . /srv/taiko-web/
sudo python3 -m venv /srv/taiko-web/.venv
sudo /srv/taiko-web/.venv/bin/pip install -U pip
sudo /srv/taiko-web/.venv/bin/pip install -r /srv/taiko-web/requirements.txt
sudo cp /srv/taiko-web/config.example.py /srv/taiko-web/config.py
```
5. 赋予 80 端口绑定权限并启动:
```bash
sudo setcap 'cap_net_bind_service=+ep' /srv/taiko-web/.venv/bin/python3
export TAIKO_WEB_MONGO_HOST=${TAIKO_WEB_MONGO_HOST:-127.0.0.1:27017}
sudo /srv/taiko-web/.venv/bin/gunicorn -b 0.0.0.0:80 app:app
```
## 手动部署(可选)
1. 安装依赖:
@@ -94,3 +143,30 @@ docker run --detach \
---
如需将监听接口改为仅内网或增加并发工作数(例如 `--workers 4`),可在 `setup.sh` 或 `systemd` 服务中调整。
## 歌曲类型Type
- 可选枚举:
- 01 Pop
- 02 Anime
- 03 Vocaloid
- 04 Children and Folk
- 05 Variety
- 06 Classical
- 07 Game Music
- 08 Live Festival Mode
- 09 Namco Original
- 10 Taiko Towers
- 11 Dan Dojo
### 上传要求
- 上传表单新增必填字段 `song_type`,取值为上述枚举之一
- 成功后将写入 MongoDB `songs.song_type`
### API 扩展
- `GET /api/songs?type=<歌曲类型>` 按类型过滤返回启用歌曲
- 示例:`/api/songs?type=02%20Anime`
- 返回项包含 `song_type` 字段
### 前端切换
- 在歌曲选择页顶部显示当前歌曲类型标签
- 使用左右跳转Shift+左右或肩键)自动切换类型并刷新列表

196
app.py
View File

@@ -13,6 +13,7 @@ import requests
import schema
import os
import time
from datetime import datetime
# -- カスタム --
import traceback
@@ -33,6 +34,7 @@ from flask_session import Session
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
from ffmpy import FFmpeg
from pymongo import MongoClient
from pymongo.errors import DuplicateKeyError
from redis import Redis
def take_config(name, required=False):
@@ -44,6 +46,19 @@ def take_config(name, required=False):
return None
app = Flask(__name__)
SONG_TYPES = [
"01 Pop",
"02 Anime",
"03 Vocaloid",
"04 Children and Folk",
"05 Variety",
"06 Classical",
"07 Game Music",
"08 Live Festival Mode",
"09 Namco Original",
"10 Taiko Towers",
"11 Dan Dojo",
]
def get_remote_address() -> str:
return flask.request.headers.get("CF-Connecting-IP") or flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr or "127.0.0.1"
@@ -89,7 +104,15 @@ sess.init_app(app)
db = client[take_config('MONGO', required=True)['database']]
db.users.create_index('username', unique=True)
db.songs.create_index('id', unique=True)
db.songs.create_index('song_type')
db.scores.create_index('username')
db.leaderboard.create_index([
('song_id', 1),
('difficulty', 1),
('month', 1),
('score', -1)
])
db.leaderboard.create_index('username')
class HashException(Exception):
@@ -479,7 +502,13 @@ def route_api_preview():
@app.route(basedir + 'api/songs')
@app.cache.cached(timeout=15)
def route_api_songs():
songs = list(db.songs.find({'enabled': True}, {'_id': False, 'enabled': False}))
type_q = flask.request.args.get('type')
query = {'enabled': True}
if type_q:
if type_q not in SONG_TYPES:
return abort(400)
query['song_type'] = type_q
songs = list(db.songs.find(query, {'_id': False, 'enabled': False}))
for song in songs:
if song['maker_id']:
if song['maker_id'] == 0:
@@ -725,6 +754,133 @@ def route_api_scores_get():
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don})
@app.route(basedir + 'api/leaderboard/submit', methods=['POST'])
@login_required
def route_api_leaderboard_submit():
data = request.get_json()
if not schema.validate(data, schema.leaderboard_submit):
return abort(400)
username = session.get('username')
user = db.users.find_one({'username': username})
current_month = datetime.now().strftime('%Y-%m')
# Handle song_id type
song_id = data['song_id']
query = {
'song_id': song_id,
'difficulty': data['difficulty'],
'month': current_month,
'username': username
}
existing = db.leaderboard.find_one(query)
new_record = False
rank_info = None
if not existing or data['score'] > existing['score']:
entry = {
'song_id': song_id,
'difficulty': data['difficulty'],
'month': current_month,
'username': username,
'display_name': user['display_name'],
'don': get_db_don(user),
'score': data['score'],
'good': data.get('good', 0),
'ok': data.get('ok', 0),
'bad': data.get('bad', 0),
'maxCombo': data.get('maxCombo', 0),
'timestamp': datetime.now()
}
db.leaderboard.update_one(query, {'$set': entry}, upsert=True)
new_record = True
# Get rank
count = db.leaderboard.count_documents({
'song_id': song_id,
'difficulty': data['difficulty'],
'month': current_month,
'score': {'$gt': data['score']}
})
rank = count + 1
rank_info = rank
return jsonify({
'status': 'ok',
'new_record': new_record,
'rank': rank_info
})
@app.route(basedir + 'api/leaderboard/get')
def route_api_leaderboard_get():
song_id = request.args.get('song_id')
difficulty = request.args.get('difficulty')
if not song_id or not difficulty:
return abort(400)
# Try convert song_id to int if it looks like one
if re.match('^[0-9]+$', str(song_id)):
song_id = int(song_id)
current_month = datetime.now().strftime('%Y-%m')
query = {
'song_id': song_id,
'difficulty': difficulty,
'month': current_month
}
# Get top 50
leaderboard = list(db.leaderboard.find(query, {'_id': False}).sort('score', -1).limit(50))
user_rank = None
if session.get('username'):
username = session.get('username')
user_entry = db.leaderboard.find_one({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month,
'username': username
}, {'_id': False})
if user_entry:
# Calculate rank
count = db.leaderboard.count_documents({
'song_id': song_id,
'difficulty': difficulty,
'month': current_month,
'score': {'$gt': user_entry['score']}
})
user_rank = {
'rank': count + 1,
'entry': user_entry
}
return jsonify({
'status': 'ok',
'leaderboard': leaderboard,
'user_rank': user_rank
})
@app.route(basedir + 'api/leaderboard/reset', methods=['POST'])
@admin_required(level=100)
def route_api_leaderboard_reset():
current_month = datetime.now().strftime('%Y-%m')
result = db.leaderboard.delete_many({'month': {'$ne': current_month}})
return jsonify({
'status': 'ok',
'deleted_count': result.deleted_count,
'current_month': current_month
})
@app.route(basedir + 'privacy')
def route_api_privacy():
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))
@@ -852,17 +1008,32 @@ def upload_file():
db_entry['enabled'] = True
pprint.pprint(db_entry)
# mongoDBにデータをぶち込む
client['taiko']["songs"].insert_one(db_entry)
# 必要な歌曲类型
song_type = flask.request.form.get('song_type')
if not song_type or song_type not in SONG_TYPES:
return flask.jsonify({'error': 'invalid_song_type'})
db_entry['song_type'] = song_type
# mongoDBにデータをぶち込む重複IDは部分更新で上書きし、_id を不変に保つ)
coll = client['taiko']["songs"]
try:
coll.insert_one(db_entry)
except DuplicateKeyError:
coll.update_one({"id": db_entry["id"]}, {"$set": db_entry}, upsert=True)
# キャッシュ削除(/api/songs
try:
app.cache.delete_memoized(route_api_songs)
except Exception:
pass
# ディレクトリを作成
target_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs")) / generated_id
target_dir.mkdir(parents=True,exist_ok=True)
base_env = os.getenv("TAIKO_WEB_SONGS_DIR")
if base_env:
base_dir = pathlib.Path(base_env)
else:
base_dir = pathlib.Path(__file__).resolve().parent / "public" / "songs"
base_dir.mkdir(parents=True, exist_ok=True)
target_dir = base_dir / generated_id
target_dir.mkdir(parents=True, exist_ok=True)
# TJAを保存
(target_dir / "main.tja").write_bytes(tja_data)
@@ -875,19 +1046,8 @@ def upload_file():
return flask.jsonify({'success': True})
@app.route("/api/delete", methods=["POST"])
@limiter.limit("1 per day")
def delete():
id = flask.request.get_json().get('id')
client["taiko"]["songs"].delete_one({ "id": id })
parent_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs"))
target_dir = parent_dir / id
if not (target_dir.resolve().parents and parent_dir.resolve() in target_dir.resolve().parents):
return flask.jsonify({ "success": False, "reason": "PARENT IS NOT ALLOWED" })
shutil.rmtree(target_dir)
return "成功しました。"
return flask.jsonify({ "success": False, "reason": "Deletion is disabled" }), 403
if __name__ == '__main__':
import argparse

Binary file not shown.

View File

@@ -6,6 +6,7 @@ var assets = {
"parseosu.js",
"titlescreen.js",
"scoresheet.js",
"leaderboard_ui.js",
"songselect.js",
"keyboard.js",
"gameinput.js",
@@ -98,7 +99,7 @@ var assets = {
"audioSfx": [
"se_pause.ogg",
"se_calibration.ogg",
"v_results.ogg",
"v_sanka.ogg",
"v_songsel.ogg",
@@ -112,14 +113,14 @@ var assets = {
"se_don.ogg",
"se_ka.ogg",
"se_jump.ogg",
"se_balloon.ogg",
"se_gameclear.ogg",
"se_gamefail.ogg",
"se_gamefullcombo.ogg",
"se_results_countup.ogg",
"se_results_crown.ogg",
"v_fullcombo.ogg",
"v_renda.ogg",
"v_results_fullcombo.ogg",
@@ -153,7 +154,7 @@ var assets = {
"customsongs.html",
"search.html"
],
"songs": [],
"sounds": {},
"image": {},

View File

@@ -1,4 +1,4 @@
class ImportSongs{
class ImportSongs{
constructor(...args){
this.init(...args)
}
@@ -322,6 +322,10 @@
songTitle = songTitle.slice(0, uraPos)
}
}
if(id === "cn" && !meta["titlecn"] && meta.titlezh){
titleLang.cn = meta.titlezh
titleLangAdded = true
}
if(meta["title" + id]){
titleLang[id] = meta["title" + id]
titleLangAdded = true
@@ -329,6 +333,10 @@
titleLang[id] = this.songTitle[songTitle][id] + ura
titleLangAdded = true
}
if(id === "cn" && !meta["subtitlecn"] && meta.subtitlezh){
subtitleLang.cn = meta.subtitlezh
subtitleLangAdded = true
}
if(meta["subtitle" + id]){
subtitleLang[id] = meta["subtitle" + id]
subtitleLangAdded = true

View File

@@ -0,0 +1,286 @@
class LeaderboardUI {
constructor(songSelect) {
this.songSelect = songSelect
this.canvasCache = new CanvasCache()
this.nameplateCache = new CanvasCache()
this.visible = false
this.loading = false
this.leaderboardData = []
this.userRank = null
this.scroll = 0
this.maxScroll = 0
this.songId = null
this.difficulty = null
this.font = strings.font
this.numbersFont = "TnT, Meiryo, sans-serif"
// UI Constants
this.width = 800
this.height = 500
this.itemHeight = 60
this.headerHeight = 80
this.padding = 20
this.closeBtn = {
x: 0, y: 0, w: 60, h: 60
}
}
show(songId, difficulty) {
this.visible = true
this.loading = true
this.songId = songId
this.difficulty = difficulty
this.leaderboardData = []
this.userRank = null
this.scroll = 0
this.fetchData(songId, difficulty)
}
hide() {
this.visible = false
this.songSelect.leaderboardActive = false
}
fetchData(songId, difficulty) {
loader.ajax(gameConfig.basedir + `api/leaderboard/get?song_id=${songId}&difficulty=${difficulty}`).then(response => {
const data = JSON.parse(response)
if (data.status === 'ok') {
this.leaderboardData = data.leaderboard
this.userRank = data.userRank
this.loading = false
// Calculate max scroll
const totalHeight = this.leaderboardData.length * this.itemHeight
const viewHeight = this.height - this.headerHeight - this.padding * 2
this.maxScroll = Math.max(0, totalHeight - viewHeight)
}
}).catch(e => {
console.error("Leaderboard fetch error:", e)
this.loading = false
})
}
draw(ctx, winW, winH, pixelRatio) {
if (!this.visible) return
const x = (winW - this.width) / 2
const y = (winH - this.height) / 2
// Draw Overlay
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"
ctx.fillRect(0, 0, winW, winH)
// Draw Window
this.songSelect.draw.roundedRect({
ctx: ctx,
x: x,
y: y,
w: this.width,
h: this.height,
radius: 20
})
ctx.fillStyle = "#fff"
ctx.fill()
// Header
ctx.fillStyle = "#ff6600"
ctx.beginPath()
ctx.moveTo(x + 20, y)
ctx.lineTo(x + this.width - 20, y)
ctx.quadraticCurveTo(x + this.width, y, x + this.width, y + 20)
ctx.lineTo(x + this.width, y + this.headerHeight)
ctx.lineTo(x, y + this.headerHeight)
ctx.lineTo(x, y + 20)
ctx.quadraticCurveTo(x, y, x + 20, y)
ctx.fill()
// Title
const diffText = strings[this.difficulty === "ura" ? "oni" : this.difficulty]
const titleText = strings.leaderboardTitle.replace("%s", diffText)
this.songSelect.draw.layeredText({
ctx: ctx,
text: titleText,
fontSize: 40,
fontFamily: this.font,
x: x + this.width / 2,
y: y + 54,
align: "center",
width: this.width - 100
}, [
{ outline: "#fff", letterBorder: 2 },
{ fill: "#000" }
])
// Close Button
this.closeBtn.x = x + this.width - 50
this.closeBtn.y = y - 10
ctx.fillStyle = "#ff0000"
ctx.beginPath()
ctx.arc(this.closeBtn.x, this.closeBtn.y, 20, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = "#fff"
ctx.lineWidth = 3
ctx.stroke()
ctx.fillStyle = "#fff"
ctx.font = "bold 24px Arial"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText("X", this.closeBtn.x, this.closeBtn.y)
// Content Area
const contentX = x + this.padding
const contentY = y + this.headerHeight + this.padding
const contentW = this.width - this.padding * 2
const contentH = this.height - this.headerHeight - this.padding * 2
ctx.save()
ctx.beginPath()
ctx.rect(contentX, contentY, contentW, contentH)
ctx.clip()
if (this.loading) {
ctx.fillStyle = "#000"
ctx.font = `30px ${this.font}`
ctx.textAlign = "center"
ctx.fillText(strings.loadingLeaderboard, x + this.width / 2, y + this.height / 2)
} else if (this.leaderboardData.length === 0) {
ctx.fillStyle = "#666"
ctx.font = `30px ${this.font}`
ctx.textAlign = "center"
ctx.fillText(strings.noScores, x + this.width / 2, y + this.height / 2)
} else {
// Draw List
let currentY = contentY - this.scroll
// Header Row
/*
ctx.fillStyle = "#eee"
ctx.fillRect(contentX, contentY, contentW, 40)
ctx.fillStyle = "#000"
ctx.font = `bold 20px ${this.font}`
ctx.textAlign = "left"
ctx.fillText(strings.rank, contentX + 20, contentY + 28)
ctx.fillText(strings.playerName, contentX + 100, contentY + 28)
ctx.textAlign = "right"
ctx.fillText(strings.score, contentX + contentW - 20, contentY + 28)
currentY += 40
*/
this.leaderboardData.forEach((entry, index) => {
if (currentY + this.itemHeight < contentY) {
currentY += this.itemHeight
return
}
if (currentY > contentY + contentH) return
const isUser = this.userRank && this.userRank.entry.username === entry.username
// Background
if (index % 2 === 0) {
ctx.fillStyle = isUser ? "#fff8e1" : "#f9f9f9"
} else {
ctx.fillStyle = isUser ? "#fff3cd" : "#fff"
}
ctx.fillRect(contentX, currentY, contentW, this.itemHeight)
// Rank
const rank = index + 1
let rankColor = "#000"
if (rank === 1) rankColor = "#ffd700"
else if (rank === 2) rankColor = "#c0c0c0"
else if (rank === 3) rankColor = "#cd7f32"
if (rank <= 3) {
ctx.fillStyle = rankColor
ctx.beginPath()
ctx.arc(contentX + 40, currentY + this.itemHeight / 2, 18, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = "#fff"
ctx.font = "bold 20px Arial"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(rank, contentX + 40, currentY + this.itemHeight / 2)
} else {
ctx.fillStyle = "#000"
ctx.font = "20px Arial"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(rank, contentX + 40, currentY + this.itemHeight / 2)
}
// Name
ctx.fillStyle = "#000"
ctx.font = `24px ${this.font}`
ctx.textAlign = "left"
ctx.fillText(entry.display_name, contentX + 90, currentY + this.itemHeight / 2)
// Score
ctx.font = `bold 28px ${this.numbersFont}`
ctx.textAlign = "right"
ctx.fillStyle = "#ff4500"
ctx.fillText(entry.score.toLocaleString(), contentX + contentW - 20, currentY + this.itemHeight / 2 + 2)
// Max Combo (small)
ctx.font = `16px ${this.font}`
ctx.fillStyle = "#666"
ctx.fillText(`${strings.maxCombo}: ${entry.maxCombo}`, contentX + contentW - 180, currentY + this.itemHeight / 2 + 5)
currentY += this.itemHeight
})
}
ctx.restore()
// Draw User Rank at Bottom if exists
if (this.userRank) {
const footerY = y + this.height - 50
ctx.fillStyle = "#333"
ctx.fillRect(x, footerY, this.width, 50)
ctx.fillStyle = "#fff"
ctx.font = `20px ${this.font}`
ctx.textAlign = "left"
ctx.textBaseline = "middle"
const rankText = strings.yourRank.replace("%s", this.userRank.rank)
ctx.fillText(rankText, x + 20, footerY + 25)
ctx.textAlign = "right"
ctx.font = `bold 24px ${this.numbersFont}`
ctx.fillText(this.userRank.entry.score.toLocaleString(), x + this.width - 20, footerY + 25)
}
}
mouseMove(x, y) {
// Handle hover if needed
}
mouseDown(x, y) {
if (!this.visible) return false
// Check Close Button
const dx = x - this.closeBtn.x
const dy = y - this.closeBtn.y
if (dx * dx + dy * dy < 20 * 20) {
this.hide()
assets.sounds["se_cancel"].play()
return true
}
// Check window click (consume event)
// Adjust coordinates
const winX = (innerWidth * (window.devicePixelRatio || 1) - this.width) / 2
/* Note: simple check logic here, might need more precise logic mapping similar to mouseWheel */
return true
}
wheel(delta) {
if (!this.visible) return
this.scroll += delta * 50
this.scroll = Math.max(0, Math.min(this.scroll, this.maxScroll))
}
}

View File

@@ -0,0 +1,26 @@
self.addEventListener('message', async e => {
const { id, url, type } = e.data
try{
const response = await fetch(url)
if(!response.ok){
throw new Error(response.status + " " + response.statusText)
}
let data
if(type === "arraybuffer"){
data = await response.arrayBuffer()
}else if(type === "blob"){
data = await response.blob()
}else{
data = await response.text()
}
self.postMessage({
id: id,
data: data
}, type === "arraybuffer" ? [data] : undefined)
}catch(e){
self.postMessage({
id: id,
error: e.toString()
})
}
})

View File

@@ -11,6 +11,8 @@ class Loader{
this.errorMessages = []
this.songSearchGradient = "linear-gradient(to top, rgba(245, 246, 252, 0.08), #ff5963), "
this.initWorkers()
var promises = []
promises.push(this.ajax("src/views/loader.html").then(page => {
@@ -23,6 +25,78 @@ class Loader{
Promise.all(promises).then(this.run.bind(this))
}
initWorkers(){
this.workers = []
this.workerQueue = []
this.workerCallbacks = {}
this.workerId = 0
var concurrency = navigator.hardwareConcurrency || 4
for(var i = 0; i < concurrency; i++){
var worker = new Worker("src/js/loader-worker.js")
worker.onmessage = this.onWorkerMessage.bind(this)
this.workers.push({
worker: worker,
active: 0
})
}
}
onWorkerMessage(e){
var data = e.data
var callback = this.workerCallbacks[data.id]
if(callback){
delete this.workerCallbacks[data.id]
if(data.error){
callback.reject(data.error)
}else{
callback.resolve(data.data)
}
this.workerFree(callback.workerIndex)
}
}
workerFetch(url, type){
return new Promise((resolve, reject) => {
var id = ++this.workerId
this.workerQueue.push({
id: id,
url: new URL(url, location.href).href,
type: type,
resolve: resolve,
reject: reject
})
this.workerRun()
})
}
workerRun(){
if(this.workerQueue.length === 0){
return
}
var workerIndex = -1
var minActive = Infinity
for(var i = 0; i < this.workers.length; i++){
if(this.workers[i].active < minActive){
minActive = this.workers[i].active
workerIndex = i
}
}
if(workerIndex !== -1){
var task = this.workerQueue.shift()
var workerObj = this.workers[workerIndex]
workerObj.active++
this.workerCallbacks[task.id] = task
task.workerIndex = workerIndex
workerObj.worker.postMessage({
id: task.id,
url: task.url,
type: task.type
})
}
}
workerFree(index){
if(this.workers[index]){
this.workers[index].active--
this.workerRun()
}
}
run(){
this.promises = []
this.loaderDiv = document.querySelector("#loader")
@@ -538,6 +612,17 @@ class Loader{
return css.join("\n")
}
ajax(url, customRequest, customResponse){
if(!customResponse && (url.startsWith("src/") || url.startsWith("assets/") || url.indexOf("img/") !== -1 || url.indexOf("audio/") !== -1 || url.indexOf("fonts/") !== -1 || url.indexOf("views/") !== -1)){
var type = "text"
if(customRequest){
var reqStub = {}
customRequest(reqStub)
if(reqStub.responseType){
type = reqStub.responseType
}
}
return this.workerFetch(url, type)
}
var request = new XMLHttpRequest()
request.open("GET", url)
var promise = pageEvents.load(request)
@@ -557,12 +642,13 @@ class Loader{
return promise
}
loadScript(url){
var script = document.createElement("script")
var url = url + this.queryString
var promise = pageEvents.load(script)
script.src = url
document.head.appendChild(script)
return promise
return this.workerFetch(url, "text").then(code => {
var script = document.createElement("script")
code += "\n//# sourceURL=" + url
script.text = code
document.head.appendChild(script)
})
}
getCsrfToken(){
return this.ajax("api/csrftoken").then(response => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ var translations = {
tw: "zh-Hant",
ko: "ko"
},
taikoWeb: {
ja: "たいこウェブ",
en: "Taiko Web",
@@ -438,7 +438,78 @@ var translations = {
tw: "連打數",
ko: "연타 횟수"
},
leaderboard: {
ja: "ランキング",
en: "Leaderboard",
cn: "排行榜",
tw: "排行榜",
ko: "순위표"
},
leaderboardTitle: {
ja: "%s の ランキング",
en: "%s Leaderboard",
cn: "%s 排行榜",
tw: "%s 排行榜",
ko: "%s 순위표"
},
rank: {
ja: "順位",
en: "Rank",
cn: "排名",
tw: "排名",
ko: "순위"
},
playerName: {
ja: "プレイヤー",
en: "Player",
cn: "玩家",
tw: "玩家",
ko: "플레이어"
},
score: {
ja: "スコア",
en: "Score",
cn: "分数",
tw: "分數",
ko: "점수"
},
recordBroken: {
ja: "最高記録を更新!第%s位",
en: "New Record! Rank #%s",
cn: "打破最佳纪录 第%s名",
tw: "打破最佳紀錄 第%s名",
ko: "최고 기록 경신! %s위"
},
yourRank: {
ja: "あなたの順位: %s位",
en: "Your Rank: #%s",
cn: "您的排名: 第%s名",
tw: "您的排名: 第%s名",
ko: "순위: %s위"
},
notRanked: {
ja: "ランク外",
en: "Not Ranked",
cn: "未上榜",
tw: "未上榜",
ko: "순위 없음"
},
loadingLeaderboard: {
ja: "ランキング読み込み中...",
en: "Loading Leaderboard...",
cn: "加载排行榜中...",
tw: "讀取排行榜中...",
ko: "순위표 로딩 중..."
},
noScores: {
ja: "記録なし",
en: "No Scores Yet",
cn: "暂无记录",
tw: "暫無紀錄",
ko: "기록 없음"
},
errorOccured: {
ja: "エラーが発生しました。再読み込みしてください。",
en: "An error occurred, please refresh",
@@ -879,7 +950,7 @@ var translations = {
en: "Audio Latency Calibration",
tw: "聲音延遲校正",
ko: "오디오 레이턴시 조절"
},
content: {
ja: "背景で鳴っている音を聴いてみましょう。\n\n音が聞こえたら、太鼓の面%sまたは%sをたたこう",
@@ -1472,26 +1543,26 @@ var translations = {
}
}
var allStrings = {}
function separateStrings(){
for(var j in languageList){
function separateStrings() {
for (var j in languageList) {
var lang = languageList[j]
allStrings[lang] = {
id: lang
}
var str = allStrings[lang]
var translateObj = function(obj, name, str){
if("en" in obj){
for(var i in obj){
var translateObj = function (obj, name, str) {
if ("en" in obj) {
for (var i in obj) {
str[name] = obj[lang] || obj.en
}
}else if(obj){
} else if (obj) {
str[name] = {}
for(var i in obj){
for (var i in obj) {
translateObj(obj[i], i, str[name])
}
}
}
for(var i in translations){
for (var i in translations) {
translateObj(translations[i], i, str)
}
}

View File

@@ -16,6 +16,21 @@
<label for="file_music">音楽ファイル:</label>
<input type="file" name="file_music" accept=".ogg,.mp3,.wav" required>
<label for="song_type">曲のタイプ:</label>
<select name="song_type" required>
<option value="01 Pop">01 Pop</option>
<option value="02 Anime">02 Anime</option>
<option value="03 Vocaloid">03 Vocaloid</option>
<option value="04 Children and Folk">04 Children and Folk</option>
<option value="05 Variety">05 Variety</option>
<option value="06 Classical">06 Classical</option>
<option value="07 Game Music">07 Game Music</option>
<option value="08 Live Festival Mode">08 Live Festival Mode</option>
<option value="09 Namco Original">09 Namco Original</option>
<option value="10 Taiko Towers">10 Taiko Towers</option>
<option value="11 Dan Dojo">11 Dan Dojo</option>
</select>
</form>
<button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button>

View File

@@ -80,3 +80,29 @@ scores_save = {
}
}
}
leaderboard_submit = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': ['integer', 'string']},
'difficulty': {'type': 'string', 'enum': ['easy', 'normal', 'hard', 'oni', 'ura']},
'score': {'type': 'integer'},
'good': {'type': 'integer'},
'ok': {'type': 'integer'},
'bad': {'type': 'integer'},
'maxCombo': {'type': 'integer'},
'hash': {'type': 'string'}
},
'required': ['difficulty', 'score']
}
leaderboard_get = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': ['integer', 'string']},
'difficulty': {'type': 'string'}
},
'required': ['song_id', 'difficulty']
}

View File

@@ -29,9 +29,9 @@
<div id="screen" class="pattern-bg"></div>
<div data-nosnippet id="version">
{% if version.version and version.commit_short and version.commit %}
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="taiko-web ver.{{version.version}} ({{version.commit_short}})">taiko-web ver.{{version.version}} ({{version.commit_short}})</a>
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
{% else %}
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="taiko-web vRAINBOW-BETA4">taiko-web vRAINBOW-BETA4</a>
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
{% endif %}
</div>
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>

22
tjaf.py
View File

@@ -7,6 +7,8 @@ class Tja:
self.text = text
self.title: Optional[str] = None
self.subtitle: Optional[str] = None
self.title_ja: Optional[str] = None
self.subtitle_ja: Optional[str] = None
self.wave: Optional[str] = None
self.offset: Optional[float] = None
self.courses: Dict[str, Dict[str, Optional[int]]] = {}
@@ -25,8 +27,12 @@ class Tja:
val = v.strip()
if key == "TITLE":
self.title = val or None
elif key == "TITLEJA":
self.title_ja = val or None
elif key == "SUBTITLE":
self.subtitle = val or None
elif key == "SUBTITLEJA":
self.subtitle_ja = val or None
elif key == "WAVE":
self.wave = val or None
elif key == "OFFSET":
@@ -73,8 +79,20 @@ class Tja:
"type": "tja",
"title": self.title,
"subtitle": self.subtitle,
"title_lang": {"ja": self.title, "en": None, "cn": None, "tw": None, "ko": None},
"subtitle_lang": {"ja": self.subtitle, "en": None, "cn": None, "tw": None, "ko": None},
"title_lang": {
"ja": self.title_ja or self.title,
"en": None,
"cn": self.title_ja or None,
"tw": None,
"ko": None,
},
"subtitle_lang": {
"ja": self.subtitle_ja or self.subtitle,
"en": None,
"cn": self.subtitle_ja or None,
"tw": None,
"ko": None,
},
"courses": courses_out,
"enabled": False,
"category_id": None,

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import os
import sys
from datetime import datetime
from pymongo import MongoClient
# Add project root to path
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
try:
import config
except ImportError:
# Handle case where config might not be in path or environment variables are used
config = None
def reset_leaderboard():
"""Delete leaderboard entries not from the current month"""
mongo_host = os.environ.get("TAIKO_WEB_MONGO_HOST")
if not mongo_host and config:
mongo_host = config.MONGO['host']
db_name = "taiko"
if config:
db_name = config.MONGO['database']
if not mongo_host:
print("Error: content not found for MONGO_HOST")
return
client = MongoClient(host=mongo_host)
db = client[db_name]
current_month = datetime.now().strftime('%Y-%m')
# Delete old month data
result = db.leaderboard.delete_many({'month': {'$ne': current_month}})
print(f"Deleted {result.deleted_count} old leaderboard entries")
print(f"Current month: {current_month}")
client.close()
if __name__ == '__main__':
reset_leaderboard()

40
update.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "${EUID}" -ne 0 ]; then echo "需要 root 权限"; exit 1; fi
SRC_DIR=$(cd "$(dirname "$0")" && pwd)
DEST_DIR=/srv/taiko-web
SONGS_DIR="$DEST_DIR/public/songs"
BACKUP_DIR="$DEST_DIR/.backup_songs_$(date +%Y%m%d_%H%M%S)"
systemctl stop taiko-web || true
if [ -d "$SONGS_DIR" ]; then
mkdir -p "$BACKUP_DIR"
rsync -a "$SONGS_DIR/" "$BACKUP_DIR/" || cp -a "$SONGS_DIR/." "$BACKUP_DIR/"
fi
mkdir -p "$DEST_DIR"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'public/songs' \
"$SRC_DIR/" "$DEST_DIR/"
if [ -x "$DEST_DIR/.venv/bin/pip" ]; then
"$DEST_DIR/.venv/bin/pip" install -U pip
"$DEST_DIR/.venv/bin/pip" install -r "$DEST_DIR/requirements.txt"
fi
chown -R www-data:www-data "$DEST_DIR"
if [ -d "$BACKUP_DIR" ]; then
mkdir -p "$SONGS_DIR"
rsync -a "$BACKUP_DIR/" "$SONGS_DIR/" || cp -a "$BACKUP_DIR/." "$SONGS_DIR/"
fi
systemctl daemon-reload || true
systemctl restart taiko-web || systemctl start taiko-web || true
systemctl is-active --quiet taiko-web