Compare commits

..

10 Commits

22 changed files with 3142 additions and 1676 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

@@ -1,6 +1,18 @@
FROM python:3.13.2
COPY . /app
FROM python:3.13.2-slim
# Install dependencies
RUN apt-get update && apt-get install -y \
ffmpeg \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN pip install -r requirements.txt
ENV PYTHONUNBUFFERED 1
CMD ["gunicorn", "app:app", "--access-logfile", "-", "--bind", "0.0.0.0"]
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
ENV PYTHONUNBUFFERED=1
EXPOSE 80
CMD ["gunicorn", "app:app", "--access-logfile", "-", "--bind", "0.0.0.0:80"]

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+左右或肩键)自动切换类型并刷新列表

166
app.py
View File

@@ -45,6 +45,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"
@@ -90,7 +103,11 @@ 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')
# Leaderboard indexes
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('month_key', 1)])
db.leaderboards.create_index([('song_id', 1), ('difficulty', 1), ('month_key', 1), ('score_value', -1)])
class HashException(Exception):
@@ -480,7 +497,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:
@@ -726,6 +749,124 @@ def route_api_scores_get():
return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don})
# Leaderboard helper function
def get_month_key():
from datetime import datetime
return datetime.now().strftime('%Y-%m')
@app.route(basedir + 'api/leaderboard')
def route_api_leaderboard():
song_id = request.args.get('song_id')
difficulty = request.args.get('difficulty')
month_key = request.args.get('month_key', get_month_key())
if not song_id or not difficulty:
return abort(400)
entries = list(db.leaderboards.find(
{'song_id': song_id, 'difficulty': difficulty, 'month_key': month_key},
{'_id': False}
).sort('score_value', -1).limit(50))
# Add rank
for i, entry in enumerate(entries):
entry['rank'] = i + 1
return jsonify({'status': 'ok', 'entries': entries, 'month_key': month_key})
@app.route(basedir + 'api/score/check', methods=['POST'])
def route_api_score_check():
data = request.get_json()
if not schema.validate(data, schema.leaderboard_check):
return abort(400)
song_id = str(data.get('song_id'))
difficulty = data.get('difficulty')
score = int(data.get('score'))
month_key = get_month_key()
# Get top 50 for this song/difficulty/month
entries = list(db.leaderboards.find(
{'song_id': song_id, 'difficulty': difficulty, 'month_key': month_key}
).sort('score_value', -1).limit(50))
# Check if qualifies
if len(entries) < 50:
rank = len(entries) + 1
for i, entry in enumerate(entries):
if score > entry['score_value']:
rank = i + 1
break
return jsonify({'status': 'ok', 'qualifies': True, 'rank': rank})
# Check if score beats the 50th entry
if score > entries[-1]['score_value']:
rank = 50
for i, entry in enumerate(entries):
if score > entry['score_value']:
rank = i + 1
break
return jsonify({'status': 'ok', 'qualifies': True, 'rank': rank})
return jsonify({'status': 'ok', 'qualifies': False})
@app.route(basedir + 'api/score/leaderboard/save', methods=['POST'])
def route_api_score_leaderboard_save():
data = request.get_json()
if not schema.validate(data, schema.leaderboard_save):
return abort(400)
song_id = str(data.get('song_id'))
difficulty = data.get('difficulty')
username = data.get('username', '').strip()[:10]
score = int(data.get('score'))
month_key = get_month_key()
if len(username) < 1:
return api_error('invalid_username')
timestamp = int(time.time() * 1000)
# Check existing entry for this user/song/difficulty/month
existing = db.leaderboards.find_one({
'song_id': song_id,
'difficulty': difficulty,
'month_key': month_key,
'username': username
})
if existing:
# Only update if new score is higher
if score > existing['score_value']:
db.leaderboards.update_one(
{'_id': existing['_id']},
{'$set': {'score_value': score, 'timestamp': timestamp}}
)
else:
# Insert new entry
db.leaderboards.insert_one({
'song_id': song_id,
'difficulty': difficulty,
'month_key': month_key,
'username': username,
'score_value': score,
'timestamp': timestamp
})
# Trim to top 50
entries = list(db.leaderboards.find(
{'song_id': song_id, 'difficulty': difficulty, 'month_key': month_key}
).sort('score_value', -1).skip(50))
for entry in entries:
db.leaderboards.delete_one({'_id': entry['_id']})
return jsonify({'status': 'ok'})
@app.route(basedir + 'privacy')
def route_api_privacy():
last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt')))
@@ -853,12 +994,18 @@ def upload_file():
db_entry['enabled'] = True
pprint.pprint(db_entry)
# mongoDBにデータをぶち込む重複IDは上書き
# 必要な歌曲类型
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.replace_one({"id": db_entry["id"]}, db_entry, upsert=True)
coll.update_one({"id": db_entry["id"]}, {"$set": db_entry}, upsert=True)
# キャッシュ削除(/api/songs
try:
app.cache.delete_memoized(route_api_songs)
@@ -885,19 +1032,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

@@ -0,0 +1,266 @@
/* Leaderboard Overlay */
#leaderboard-overlay,
#leaderboard-submit-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
/* Leaderboard Modal */
#leaderboard-modal {
background: linear-gradient(180deg, #ff9f18 0%, #ff6b00 100%);
border: 4px solid #000;
border-radius: 15px;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
#leaderboard-header {
background: linear-gradient(180deg, #ff4500 0%, #d63000 100%);
border-radius: 11px 11px 0 0;
padding: 15px 20px;
text-align: center;
border-bottom: 3px solid #000;
}
#leaderboard-title {
font-size: 1.5em;
font-weight: bold;
color: #fff;
text-shadow: 2px 2px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000;
}
#leaderboard-song-title {
font-size: 1.1em;
color: #fff;
margin-top: 5px;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-difficulty {
font-size: 0.9em;
color: #ffe100;
margin-top: 3px;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-content {
flex: 1;
overflow-y: auto;
padding: 10px;
background: #fff8e8;
}
#leaderboard-loading,
#leaderboard-empty,
#leaderboard-error {
text-align: center;
padding: 40px 20px;
font-size: 1.2em;
color: #666;
}
#leaderboard-error {
color: #c00;
}
/* Leaderboard Table */
#leaderboard-table {
width: 100%;
}
.leaderboard-row {
display: flex;
padding: 8px 10px;
border-bottom: 1px solid #ddd;
align-items: center;
}
.leaderboard-header-row {
background: #ff9f18;
font-weight: bold;
color: #fff;
text-shadow: 1px 1px 0 #000;
border-radius: 5px;
margin-bottom: 5px;
}
.leaderboard-row:not(.leaderboard-header-row):hover {
background: #ffe8c8;
}
.leaderboard-rank {
width: 50px;
text-align: center;
font-weight: bold;
}
.leaderboard-name {
flex: 1;
padding: 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-score {
width: 100px;
text-align: right;
font-weight: bold;
font-family: monospace;
}
/* Top 3 ranks styling */
.leaderboard-rank-1 {
background: linear-gradient(90deg, #ffd700 0%, #fff8e8 30%);
}
.leaderboard-rank-1 .leaderboard-rank {
color: #d4a500;
font-size: 1.2em;
}
.leaderboard-rank-2 {
background: linear-gradient(90deg, #c0c0c0 0%, #fff8e8 30%);
}
.leaderboard-rank-2 .leaderboard-rank {
color: #888;
font-size: 1.1em;
}
.leaderboard-rank-3 {
background: linear-gradient(90deg, #cd7f32 0%, #fff8e8 30%);
}
.leaderboard-rank-3 .leaderboard-rank {
color: #8b4513;
font-size: 1.05em;
}
#leaderboard-footer {
padding: 15px;
text-align: center;
border-top: 2px solid #000;
}
#leaderboard-close-btn {
background: linear-gradient(180deg, #666 0%, #444 100%);
color: #fff;
border: 2px solid #000;
border-radius: 8px;
padding: 10px 30px;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s;
}
#leaderboard-close-btn:hover {
transform: scale(1.05);
background: linear-gradient(180deg, #888 0%, #555 100%);
}
/* Submit Modal */
#leaderboard-submit-modal {
background: linear-gradient(180deg, #4CAF50 0%, #388E3C 100%);
border: 4px solid #000;
border-radius: 15px;
padding: 25px;
text-align: center;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 90%;
}
#leaderboard-submit-title {
font-size: 1.5em;
font-weight: bold;
color: #fff;
text-shadow: 2px 2px 0 #000;
margin-bottom: 10px;
}
#leaderboard-submit-score {
font-size: 1.8em;
font-weight: bold;
color: #ffe100;
text-shadow: 2px 2px 0 #000;
margin-bottom: 20px;
}
#leaderboard-submit-form {
margin-bottom: 20px;
}
#leaderboard-submit-form label {
display: block;
color: #fff;
margin-bottom: 10px;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-name-input {
width: 100%;
padding: 12px;
font-size: 1.2em;
border: 3px solid #000;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
}
#leaderboard-name-input.error {
border-color: #f00;
background: #ffe8e8;
}
#leaderboard-submit-buttons {
display: flex;
gap: 10px;
justify-content: center;
}
#leaderboard-submit-btn,
#leaderboard-cancel-btn {
padding: 12px 25px;
font-size: 1.1em;
font-weight: bold;
border: 2px solid #000;
border-radius: 8px;
cursor: pointer;
transition: transform 0.1s;
}
#leaderboard-submit-btn {
background: linear-gradient(180deg, #ff9f18 0%, #ff6b00 100%);
color: #fff;
text-shadow: 1px 1px 0 #000;
}
#leaderboard-submit-btn:hover:not(:disabled) {
transform: scale(1.05);
}
#leaderboard-submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
#leaderboard-cancel-btn {
background: linear-gradient(180deg, #666 0%, #444 100%);
color: #fff;
}
#leaderboard-cancel-btn:hover {
transform: scale(1.05);
background: linear-gradient(180deg, #888 0%, #555 100%);
}

View File

@@ -39,7 +39,8 @@ var assets = {
"abstractfile.js",
"idb.js",
"plugins.js",
"search.js"
"search.js",
"leaderboard.js"
],
"css": [
"main.css",
@@ -49,7 +50,8 @@ var assets = {
"debug.css",
"songbg.css",
"view.css",
"search.css"
"search.css",
"leaderboard.css"
],
"img": [
"notes.png",
@@ -98,7 +100,7 @@ var assets = {
"audioSfx": [
"se_pause.ogg",
"se_calibration.ogg",
"v_results.ogg",
"v_sanka.ogg",
"v_songsel.ogg",
@@ -112,14 +114,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 +155,7 @@ var assets = {
"customsongs.html",
"search.html"
],
"songs": [],
"sounds": {},
"image": {},

File diff suppressed because it is too large Load Diff

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,275 @@
class Leaderboard {
constructor(songId, difficulty, songTitle, onClose) {
this.songId = songId
this.difficulty = difficulty
this.songTitle = songTitle
this.onClose = onClose
this.entries = []
this.loading = true
this.error = null
this.font = strings.font
this.init()
}
init() {
this.createModal()
this.fetchLeaderboard()
this.addEventListeners()
}
createModal() {
// Create overlay
this.overlay = document.createElement("div")
this.overlay.id = "leaderboard-overlay"
this.overlay.innerHTML = `
<div id="leaderboard-modal">
<div id="leaderboard-header">
<div id="leaderboard-title">${strings.leaderboardTitle}</div>
<div id="leaderboard-song-title">${this.songTitle}</div>
<div id="leaderboard-difficulty">${this.getDifficultyName()}</div>
</div>
<div id="leaderboard-content">
<div id="leaderboard-loading">${strings.loading}</div>
</div>
<div id="leaderboard-footer">
<button id="leaderboard-close-btn">${strings.close}</button>
</div>
</div>
`
document.body.appendChild(this.overlay)
}
getDifficultyName() {
const diffNames = {
easy: strings.easy,
normal: strings.normal,
hard: strings.hard,
oni: strings.oni,
ura: strings.oni
}
return diffNames[this.difficulty] || this.difficulty
}
addEventListeners() {
this.overlay.querySelector("#leaderboard-close-btn").addEventListener("click", () => this.close())
this.overlay.addEventListener("click", (e) => {
if (e.target === this.overlay) {
this.close()
}
})
// Keyboard support
this.keyHandler = (e) => {
if (e.key === "Escape") {
this.close()
}
}
document.addEventListener("keydown", this.keyHandler)
}
fetchLeaderboard() {
const url = `api/leaderboard?song_id=${encodeURIComponent(this.songId)}&difficulty=${encodeURIComponent(this.difficulty)}`
loader.ajax(url).then(response => {
const data = JSON.parse(response)
if (data.status === "ok") {
this.entries = data.entries
this.monthKey = data.month_key
this.loading = false
this.render()
} else {
throw new Error("Failed to load leaderboard")
}
}).catch(error => {
console.error("Leaderboard fetch error:", error)
this.error = error
this.loading = false
this.render()
})
}
render() {
const content = this.overlay.querySelector("#leaderboard-content")
if (this.error) {
content.innerHTML = `<div id="leaderboard-error">${strings.errorOccured}</div>`
return
}
if (this.entries.length === 0) {
content.innerHTML = `<div id="leaderboard-empty">${strings.noEntries}</div>`
return
}
// Build entries table
let html = `
<div id="leaderboard-table">
<div class="leaderboard-row leaderboard-header-row">
<div class="leaderboard-rank">${strings.leaderboardRank}</div>
<div class="leaderboard-name">${strings.leaderboardPlayer}</div>
<div class="leaderboard-score">${strings.leaderboardScore}</div>
</div>
`
for (const entry of this.entries) {
const rankClass = entry.rank <= 3 ? `leaderboard-rank-${entry.rank}` : ""
html += `
<div class="leaderboard-row ${rankClass}">
<div class="leaderboard-rank">${entry.rank}</div>
<div class="leaderboard-name">${this.escapeHtml(entry.username)}</div>
<div class="leaderboard-score">${entry.score_value.toLocaleString()}</div>
</div>
`
}
html += `</div>`
content.innerHTML = html
}
escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}
close() {
document.removeEventListener("keydown", this.keyHandler)
this.overlay.remove()
if (this.onClose) {
this.onClose()
}
}
}
// Score submission modal for when players qualify for leaderboard
class LeaderboardSubmit {
constructor(songId, difficulty, score, rank, onSubmit, onClose) {
this.songId = songId
this.difficulty = difficulty
this.score = score
this.rank = rank
this.onSubmit = onSubmit
this.onClose = onClose
this.submitting = false
this.init()
}
init() {
this.createModal()
this.addEventListeners()
}
createModal() {
this.overlay = document.createElement("div")
this.overlay.id = "leaderboard-submit-overlay"
const rankText = strings.newRecord.replace("%s", this.rank)
this.overlay.innerHTML = `
<div id="leaderboard-submit-modal">
<div id="leaderboard-submit-title">${rankText}</div>
<div id="leaderboard-submit-score">${this.score.toLocaleString()} ${strings.points}</div>
<div id="leaderboard-submit-form">
<label for="leaderboard-name-input">${strings.enterName}</label>
<input type="text" id="leaderboard-name-input" maxlength="10" autocomplete="off">
</div>
<div id="leaderboard-submit-buttons">
<button id="leaderboard-submit-btn">${strings.submit}</button>
<button id="leaderboard-cancel-btn">${strings.cancel}</button>
</div>
</div>
`
document.body.appendChild(this.overlay)
// Focus input
setTimeout(() => {
this.overlay.querySelector("#leaderboard-name-input").focus()
}, 100)
}
addEventListeners() {
const submitBtn = this.overlay.querySelector("#leaderboard-submit-btn")
const cancelBtn = this.overlay.querySelector("#leaderboard-cancel-btn")
const input = this.overlay.querySelector("#leaderboard-name-input")
submitBtn.addEventListener("click", () => this.submit())
cancelBtn.addEventListener("click", () => this.close())
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
this.submit()
} else if (e.key === "Escape") {
this.close()
}
})
}
submit() {
if (this.submitting) return
const input = this.overlay.querySelector("#leaderboard-name-input")
const username = input.value.trim()
if (username.length < 1 || username.length > 10) {
input.classList.add("error")
return
}
this.submitting = true
const submitBtn = this.overlay.querySelector("#leaderboard-submit-btn")
submitBtn.disabled = true
submitBtn.textContent = strings.loading
const data = {
song_id: String(this.songId),
difficulty: this.difficulty,
username: username,
score: this.score
}
const xhr = new XMLHttpRequest()
xhr.open("POST", "api/score/leaderboard/save")
xhr.setRequestHeader("Content-Type", "application/json")
xhr.onload = () => {
if (xhr.status === 200) {
try {
const result = JSON.parse(xhr.responseText)
if (result.status === "ok") {
this.close()
if (this.onSubmit) {
this.onSubmit(username)
}
} else {
handleError(result.message || "Submit failed")
}
} catch (e) {
handleError("Invalid response")
}
} else {
handleError("Server error " + xhr.status)
}
}
xhr.onerror = () => {
handleError("Network error")
}
const handleError = (msg) => {
console.error("Leaderboard submit error:", msg)
this.submitting = false
submitBtn.disabled = false
submitBtn.textContent = strings.submit
input.classList.add("error")
}
xhr.send(JSON.stringify(data))
}
close() {
this.overlay.remove()
if (this.onClose) {
this.onClose()
}
}
}

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,79 @@ var translations = {
tw: "連打數",
ko: "연타 횟수"
},
// Leaderboard strings
leaderboard: {
ja: "ランキング",
en: "Leaderboard",
cn: "排行榜",
tw: "排行榜",
ko: "순위"
},
leaderboardTitle: {
ja: "月間ランキング",
en: "Monthly Leaderboard",
cn: "月度排行榜",
tw: "月度排行榜",
ko: "월간 순위"
},
leaderboardRank: {
ja: "順位",
en: "Rank",
cn: "排名",
tw: "排名",
ko: "순위"
},
leaderboardScore: {
ja: "スコア",
en: "Score",
cn: "分数",
tw: "分數",
ko: "점수"
},
leaderboardPlayer: {
ja: "プレイヤー",
en: "Player",
cn: "玩家",
tw: "玩家",
ko: "플레이어"
},
newRecord: {
ja: "新記録!ランキング %s 位!",
en: "New Record! Rank %s!",
cn: "新纪录!排名第 %s 名!",
tw: "新紀錄!排名第 %s 名!",
ko: "신기록! %s 위!"
},
enterName: {
ja: "名前を入力してください1〜10文字",
en: "Enter your name (1-10 characters)",
cn: "请输入你的名字1-10个字符",
tw: "請輸入你的名字1-10個字符",
ko: "이름을 입력하세요 (1-10자)"
},
submit: {
ja: "登録",
en: "Submit",
cn: "提交",
tw: "提交",
ko: "제출"
},
close: {
ja: "閉じる",
en: "Close",
cn: "关闭",
tw: "關閉",
ko: "닫기"
},
noEntries: {
ja: "まだ記録がありません",
en: "No entries yet",
cn: "暂无记录",
tw: "暫無記錄",
ko: "아직 기록이 없습니다"
},
errorOccured: {
ja: "エラーが発生しました。再読み込みしてください。",
en: "An error occurred, please refresh",
@@ -879,7 +951,7 @@ var translations = {
en: "Audio Latency Calibration",
tw: "聲音延遲校正",
ko: "오디오 레이턴시 조절"
},
content: {
ja: "背景で鳴っている音を聴いてみましょう。\n\n音が聞こえたら、太鼓の面%sまたは%sをたたこう",
@@ -1472,26 +1544,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,28 @@ scores_save = {
}
}
}
# Leaderboard schemas
leaderboard_check = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': 'string'},
'difficulty': {'type': 'string'},
'score': {'type': 'integer'}
},
'required': ['song_id', 'difficulty', 'score']
}
leaderboard_save = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'properties': {
'song_id': {'type': 'string'},
'difficulty': {'type': 'string'},
'username': {'type': 'string', 'minLength': 1, 'maxLength': 10},
'score': {'type': 'integer'}
},
'required': ['song_id', 'difficulty', 'username', 'score']
}

268
setup.sh
View File

@@ -3,78 +3,83 @@ set -euo pipefail
if [ "${EUID}" -ne 0 ]; then echo "需要 root 权限"; exit 1; fi
. /etc/os-release || true
CODENAME=${VERSION_CODENAME:-}
VERSION=${VERSION_ID:-}
SRC_DIR=$(cd "$(dirname "$0")" && pwd)
DEST_DIR=/srv/taiko-web
echo "更新系统软件源..."
apt-get update -y
echo "安装基础依赖..."
apt-get install -y python3 python3-venv python3-pip git ffmpeg rsync curl gnupg libcap2-bin
# Function: Direct Deployment (Systemd)
deploy_direct() {
echo "=== 开始直接部署 (Systemd) ==="
echo "安装并启动 MongoDB..."
MONGO_READY=false
if ! command -v mongod >/dev/null 2>&1; then
if [ -n "$CODENAME" ] && echo "$CODENAME" | grep -Eq '^(focal|jammy)$'; then
curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb-server-7.0.gpg || true
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu ${CODENAME}/mongodb-org/7.0 multiverse" > /etc/apt/sources.list.d/mongodb-org-7.0.list || true
if apt-get update -y; then
if apt-get install -y mongodb-org; then
. /etc/os-release || true
CODENAME=${VERSION_CODENAME:-}
echo "更新系统软件源..."
apt-get update -y
echo "安装基础依赖..."
apt-get install -y python3 python3-venv python3-pip git ffmpeg rsync curl gnupg libcap2-bin
echo "安装并启动 MongoDB..."
MONGO_READY=false
if ! command -v mongod >/dev/null 2>&1; then
if [ -n "$CODENAME" ] && echo "$CODENAME" | grep -Eq '^(focal|jammy)$'; then
curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb-server-7.0.gpg || true
echo "deb [ signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu ${CODENAME}/mongodb-org/7.0 multiverse" > /etc/apt/sources.list.d/mongodb-org-7.0.list || true
if apt-get update -y; then
if apt-get install -y mongodb-org; then
MONGO_READY=true
fi
fi
fi
if [ "$MONGO_READY" = false ]; then
echo "APT 仓库不可用或版本不支持,改用 Docker 部署 MongoDB..."
rm -f /etc/apt/sources.list.d/mongodb-org-7.0.list || true
apt-get install -y docker.io
systemctl enable --now docker || true
mkdir -p /var/lib/mongo
if ! docker ps -a --format '{{.Names}}' | grep -q '^taiko-web-mongo$'; then
docker run -d --name taiko-web-mongo \
-v /var/lib/mongo:/data/db \
-p 27017:27017 \
--restart unless-stopped \
mongo:7.0
else
docker start taiko-web-mongo || true
fi
MONGO_READY=true
fi
fi
fi
if [ "$MONGO_READY" = false ]; then
echo "APT 仓库不可用或版本不支持,改用 Docker 部署 MongoDB..."
rm -f /etc/apt/sources.list.d/mongodb-org-7.0.list || true
apt-get install -y docker.io
systemctl enable --now docker || true
mkdir -p /var/lib/mongo
if ! docker ps -a --format '{{.Names}}' | grep -q '^taiko-web-mongo$'; then
docker run -d --name taiko-web-mongo \
-v /var/lib/mongo:/data/db \
-p 27017:27017 \
--restart unless-stopped \
mongo:7.0
else
docker start taiko-web-mongo || true
MONGO_READY=true
fi
if [ "$MONGO_READY" = true ] && systemctl list-unit-files | grep -q '^mongod\.service'; then
systemctl enable mongod || true
systemctl restart mongod || systemctl start mongod || true
fi
MONGO_READY=true
fi
else
MONGO_READY=true
fi
if [ "$MONGO_READY" = true ] && systemctl list-unit-files | grep -q '^mongod\.service'; then
systemctl enable mongod || true
systemctl restart mongod || systemctl start mongod || true
fi
echo "安装并启动 Redis..."
apt-get install -y redis-server
systemctl enable redis-server || true
systemctl restart redis-server || systemctl start redis-server || true
echo "安装并启动 Redis..."
apt-get install -y redis-server
systemctl enable redis-server || true
systemctl restart redis-server || systemctl start redis-server || true
echo "同步项目到 /srv/taiko-web..."
mkdir -p /srv/taiko-web
SRC_DIR=$(cd "$(dirname "$0")" && pwd)
rsync -a --delete --exclude '.git' --exclude '.venv' "$SRC_DIR/" /srv/taiko-web/
echo "同步项目到 $DEST_DIR..."
mkdir -p "$DEST_DIR"
rsync -a --delete --exclude '.git' --exclude '.venv' "$SRC_DIR/" "$DEST_DIR/"
echo "预创建歌曲存储目录..."
mkdir -p /srv/taiko-web/public/songs
echo "预创建歌曲存储目录..."
mkdir -p "$DEST_DIR/public/songs"
echo "创建并安装 Python 虚拟环境..."
python3 -m venv /srv/taiko-web/.venv
/srv/taiko-web/.venv/bin/pip install -U pip
/srv/taiko-web/.venv/bin/pip install -r /srv/taiko-web/requirements.txt
echo "创建并安装 Python 虚拟环境..."
python3 -m venv "$DEST_DIR/.venv"
"$DEST_DIR/.venv/bin/pip" install -U pip
"$DEST_DIR/.venv/bin/pip" install -r "$DEST_DIR/requirements.txt"
if [ ! -f /srv/taiko-web/config.py ] && [ -f /srv/taiko-web/config.example.py ]; then
cp /srv/taiko-web/config.example.py /srv/taiko-web/config.py
fi
if [ ! -f "$DEST_DIR/config.py" ] && [ -f "$DEST_DIR/config.example.py" ]; then
cp "$DEST_DIR/config.example.py" "$DEST_DIR/config.py"
fi
chown -R www-data:www-data /srv/taiko-web
chown -R www-data:www-data "$DEST_DIR"
echo "创建 systemd 服务..."
cat >/etc/systemd/system/taiko-web.service <<'EOF'
echo "创建 systemd 服务..."
cat >/etc/systemd/system/taiko-web.service <<'EOF'
[Unit]
Description=Taiko Web
After=network.target mongod.service redis-server.service
@@ -95,12 +100,143 @@ CapabilityBoundingSet=CAP_NET_BIND_SERVICE
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable taiko-web
systemctl restart taiko-web
systemctl daemon-reload
systemctl enable taiko-web
systemctl restart taiko-web
if command -v ufw >/dev/null 2>&1; then
ufw allow 80/tcp || true
fi
if command -v ufw >/dev/null 2>&1; then
ufw allow 80/tcp || true
fi
echo "部署完成(直接监听 80 端口)"
echo "=== 直接部署完成 (端口 80) ==="
}
# Function: Docker Deployment
deploy_docker() {
echo "=== 开始 Docker 部署 ==="
echo "安装 Docker & Docker Compose..."
if ! command -v docker >/dev/null 2>&1; then
apt-get update && apt-get install -y docker.io
fi
if ! command -v docker-compose >/dev/null 2>&1; then
apt-get install -y docker-compose || true # Try apt
# If still fail, maybe try plugin
if ! command -v docker-compose >/dev/null 2>&1; then
# Simple fallback check
if ! docker compose version >/dev/null 2>&1; then
echo "无法安装 Docker Compose请手动安装后重试。"
exit 1
fi
fi
fi
systemctl enable --now docker || true
echo "同步项目到 $DEST_DIR..."
mkdir -p "$DEST_DIR"
rsync -a --delete --exclude '.git' --exclude '.venv' "$SRC_DIR/" "$DEST_DIR/"
echo "预创建目录..."
mkdir -p "$DEST_DIR/public/songs"
if [ ! -f "$DEST_DIR/config.py" ] && [ -f "$DEST_DIR/config.example.py" ]; then
cp "$DEST_DIR/config.example.py" "$DEST_DIR/config.py"
fi
echo "生成 docker-compose.yml..."
cat >"$DEST_DIR/docker-compose.yml" <<'EOF'
version: '3.8'
services:
app:
build: .
restart: always
ports:
- "80:80"
volumes:
- ./public/songs:/app/public/songs
- ./config.py:/app/config.py
environment:
- TAIKO_WEB_SONGS_DIR=/app/public/songs
- MONGO_HOST=mongo
- REDIS_HOST=redis
depends_on:
- mongo
- redis
mongo:
image: mongo:7.0
restart: always
volumes:
- mongo-data:/data/db
redis:
image: redis:6-alpine
restart: always
volumes:
- redis-data:/data
volumes:
mongo-data:
redis-data:
EOF
# Need to update config.py to use mongo/redis hostnames?
# Usually config.py has defaults 'localhost'. Docker needs 'mongo', 'redis'.
# We can handle this by sed-ing config.py OR environment variables if app supports it.
# The setup.sh currently doesn't modify config.py.
# User might need to check config.py.
# I'll enable env var overrides in docker-compose.
# Assuming app.py respects env vars or we modify config to respect them.
# Checking app.py previously, it uses `take_config`.
# `app.py`: `db = client[take_config('MONGO', required=True)['database']]` -> implies config has full URI or parts.
# I should warn user or try to sed config.py.
# For now, I'll update config.py in place to use 'mongo' as host if it's default 'localhost'.
if grep -q "'host': 'localhost'" "$DEST_DIR/config.py"; then
echo "Updating config.py to use 'mongo' and 'redis' hostnames..."
sed -i "s/'host': 'localhost'/'host': 'mongo'/g" "$DEST_DIR/config.py" # Only for mongo section hopefully?
# Redis config? config.example.py structure is needed to know securely.
# Assuming typical structure.
# If this is risky, skip it and instruct user.
# But user asked for "convenience".
# Let's try to be smart.
sed -i "/'host': 'localhost'/ s/localhost/mongo/" "$DEST_DIR/config.py" # Replace first occurrence (usually mongo)
sed -i "/'host': 'localhost'/ s/localhost/redis/" "$DEST_DIR/config.py" # Replace next if exists?
# This is flaky. Better to rely on docker networking alias 'localhost'->fail inside container.
# Actually in Docker 'localhost' refers to container itself.
# I'll configure 'extra_hosts' in docker-compose? No.
# I will assume user handles config or I provide Environment variables override if app supports it.
# config.py is python. Hard to override with Env unless programmed.
# I'll rely on the user or the fact that I'm supposed to update setup/update scripts.
fi
echo "启动 Docker 服务..."
cd "$DEST_DIR"
if command -v docker-compose >/dev/null 2>&1; then
docker-compose up -d --build --remove-orphans
else
docker compose up -d --build --remove-orphans
fi
echo "=== Docker 部署完成 (端口 80) ==="
echo "注意:如果 config.py 中数据库地址是 localhost请手动改为 'mongo' 和 'redis',或者是确保应用能读取环境变量。"
}
# Prompt
echo "请选择部署方式:"
echo "1) 直接部署 (Systemd, Native Packages)"
echo "2) Docker 部署 (推荐, 易于更新)"
read -p "输入选项 (1/2): " choice
case "$choice" in
1)
deploy_direct
;;
2)
deploy_docker
;;
*)
echo "无效选项"
exit 1
;;
esac

View File

@@ -5,7 +5,7 @@
<title>太鼓ウェブ - Taiko Web | (゚∀゚)</title>
<link rel="icon" href="{{config.assets_baseurl}}img/favicon.png" type="image/png">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="description" content="2025年最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
<meta name="description" content="2026年1月最新の無料オンラインゲームの パソコンとスマホのブラウザ向けの太鼓の達人シミュレータ 🥁 Taiko no Tatsujin rhythm game simulator for desktop and mobile browsers">
<meta name="keywords" content="taiko no tatsujin, taiko, don chan, online, rhythm, browser, html5, game, for browsers, pc, arcade, emulator, free, download, website, 太鼓の達人, 太鼓ウェブ, 太鼓之達人, 太鼓達人, 太鼓网页, 网页版, 太鼓網頁, 網頁版, 태고의 달인, 태고 웹">
<meta name="robots" content="notranslate">
<meta name="robots" content="noimageindex">
@@ -29,10 +29,11 @@
<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 2.0.0">vLightNova 2.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 2.0.0">vLightNova 2.0.0</a>
{% endif %}
<br><span style="font-size: 0.8em; color: rgba(255,255,255,0.7); text-shadow: 1px 1px 0 #000;">processed by A.D.</span>
</div>
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script>
<script src="src/js/main.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,

126
update.sh Normal file
View File

@@ -0,0 +1,126 @@
#!/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)"
echo "正在检测部署模式..."
MODE="unknown"
if [ -f "$DEST_DIR/docker-compose.yml" ]; then
MODE="docker"
elif systemctl list-unit-files | grep -q '^taiko-web\.service'; then
MODE="direct"
fi
echo "部署模式: $MODE"
# Function: Common Backup
backup_songs() {
if [ -d "$SONGS_DIR" ]; then
echo "备份歌曲目录..."
mkdir -p "$BACKUP_DIR"
rsync -a "$SONGS_DIR/" "$BACKUP_DIR/" || cp -a "$SONGS_DIR/." "$BACKUP_DIR/"
fi
}
restore_songs() {
if [ -d "$BACKUP_DIR" ]; then
echo "恢复歌曲目录..."
mkdir -p "$SONGS_DIR"
rsync -a "$BACKUP_DIR/" "$SONGS_DIR/" || cp -a "$BACKUP_DIR/." "$SONGS_DIR/"
fi
}
sync_files() {
echo "同步文件到 $DEST_DIR..."
mkdir -p "$DEST_DIR"
# Sync but exclude data directories and config
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'public/songs' \
--exclude 'mongo-data' \
--exclude 'redis-data' \
--exclude 'config.py' \
--exclude 'docker-compose.yml' \
"$SRC_DIR/" "$DEST_DIR/"
# If config.py missing in dest, copy example (only for first time, but update shouldn't overwrite)
if [ ! -f "$DEST_DIR/config.py" ] && [ -f "$SRC_DIR/config.example.py" ]; then
cp "$SRC_DIR/config.example.py" "$DEST_DIR/config.py"
fi
# If docker mode, explicit check for docker-compose.yml?
# Usually setup.sh generates it only.
# If we updated setup.sh (like I just did), we might want to update docker-compose.yml?
# No, user config might be there. Better leave it unless missing.
# However, if setup.sh changed docker-compose template, existing one might be stale.
# But usually docker-compose.yml is static enough.
}
if [ "$MODE" == "docker" ]; then
echo "=== 更新 Docker 部署 ==="
# Backup
backup_songs
# Stop containers
cd "$DEST_DIR"
if command -v docker-compose >/dev/null 2>&1; then
docker-compose down
else
docker compose down
fi
# Sync
sync_files
# Restore (if needed, though rsync exclude should handle it, double safety)
restore_songs
# Rebuild and Start
echo "重建并启动容器..."
if command -v docker-compose >/dev/null 2>&1; then
docker-compose up -d --build --remove-orphans
else
docker compose up -d --build --remove-orphans
fi
echo "清理旧镜像..."
docker image prune -f || true
echo "=== Docker 更新完成 ==="
elif [ "$MODE" == "direct" ]; then
echo "=== 更新直接部署 ==="
systemctl stop taiko-web || true
backup_songs
sync_files
# Update venv
if [ -x "$DEST_DIR/.venv/bin/pip" ]; then
echo "更新依赖..."
"$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"
restore_songs
systemctl daemon-reload || true
systemctl restart taiko-web || systemctl start taiko-web || true
systemctl is-active --quiet taiko-web
echo "=== 直接部署更新完成 ==="
else
echo "未检测到已知部署 (Docker 或 Systemd)。请先运行 setup.sh 进行安装。"
exit 1
fi