Add MusicWorkshop application
This commit is contained in:
Executable
+436
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
METADATA_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT_ROOT="$(cd "$METADATA_ROOT_DIR/../.." && pwd)"
|
||||
RUNTIME_DIR="$PROJECT_ROOT/.dev-runtime/metadata"
|
||||
|
||||
|
||||
log() {
|
||||
printf '[metadata] %s\n' "$*"
|
||||
}
|
||||
|
||||
|
||||
fail() {
|
||||
printf '[metadata] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
load_config() {
|
||||
[[ -f "$METADATA_ROOT_DIR/versions.lock" ]] || fail "缺少 versions.lock"
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$METADATA_ROOT_DIR/versions.lock"
|
||||
if [[ -f "$METADATA_ROOT_DIR/.env.local" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$METADATA_ROOT_DIR/.env.local"
|
||||
fi
|
||||
set +a
|
||||
|
||||
: "${NETEASE_SOURCE_TYPE:?}"
|
||||
: "${NETEASE_DIR_REL:?}"
|
||||
: "${NETEASE_HOST:?}"
|
||||
: "${NETEASE_PORT:?}"
|
||||
: "${QQ_SOURCE_TYPE:?}"
|
||||
: "${QQ_REPO_URL:?}"
|
||||
: "${QQ_REF:?}"
|
||||
: "${QQ_DIR_REL:?}"
|
||||
: "${QQ_HOST:?}"
|
||||
: "${QQ_PORT:?}"
|
||||
: "${QQ_DEFAULT_UPSTREAM_PORT:?}"
|
||||
: "${SEARCH_SMOKE_KEYWORDS:?}"
|
||||
: "${SEARCH_SMOKE_LIMIT:?}"
|
||||
|
||||
NETEASE_DIR="$METADATA_ROOT_DIR/$NETEASE_DIR_REL"
|
||||
QQ_DIR="$METADATA_ROOT_DIR/$QQ_DIR_REL"
|
||||
|
||||
if [[ "$NETEASE_SOURCE_TYPE" == "npm" ]]; then
|
||||
: "${NETEASE_PACKAGE_NAME:?}"
|
||||
: "${NETEASE_PACKAGE_VERSION:?}"
|
||||
elif [[ "$NETEASE_SOURCE_TYPE" == "git" ]]; then
|
||||
: "${NETEASE_REPO_URL:?}"
|
||||
: "${NETEASE_REF:?}"
|
||||
else
|
||||
fail "不支持的 NETEASE_SOURCE_TYPE: $NETEASE_SOURCE_TYPE"
|
||||
fi
|
||||
|
||||
if [[ "$QQ_SOURCE_TYPE" == "git" ]]; then
|
||||
: "${QQ_REPO_URL:?}"
|
||||
: "${QQ_REF:?}"
|
||||
else
|
||||
fail "不支持的 QQ_SOURCE_TYPE: $QQ_SOURCE_TYPE"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
ensure_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "当前环境缺少命令: $1"
|
||||
}
|
||||
|
||||
|
||||
ensure_runtime_dir() {
|
||||
mkdir -p "$RUNTIME_DIR"
|
||||
}
|
||||
|
||||
|
||||
pid_file_for() {
|
||||
printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1"
|
||||
}
|
||||
|
||||
|
||||
log_file_for() {
|
||||
printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"
|
||||
}
|
||||
|
||||
|
||||
pid_from_file() {
|
||||
local pid_file="$1"
|
||||
|
||||
[[ -f "$pid_file" ]] || return 1
|
||||
|
||||
local pid
|
||||
pid="$(tr -d '[:space:]' < "$pid_file")"
|
||||
[[ -n "$pid" ]] || return 1
|
||||
printf '%s\n' "$pid"
|
||||
}
|
||||
|
||||
|
||||
is_pid_running() {
|
||||
local pid="$1"
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
|
||||
cleanup_stale_pid_file() {
|
||||
local pid_file="$1"
|
||||
|
||||
if pid="$(pid_from_file "$pid_file" 2>/dev/null)"; then
|
||||
if ! is_pid_running "$pid"; then
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
elif [[ -f "$pid_file" ]]; then
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
active_pid_from_file() {
|
||||
local pid_file="$1"
|
||||
|
||||
cleanup_stale_pid_file "$pid_file"
|
||||
|
||||
if pid="$(pid_from_file "$pid_file" 2>/dev/null)" && is_pid_running "$pid"; then
|
||||
printf '%s\n' "$pid"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
ensure_service_source() {
|
||||
local service_name="$1"
|
||||
local service_dir="$2"
|
||||
|
||||
[[ -d "$service_dir" ]] || fail "$service_name 源码目录不存在: $service_dir,请先执行 bootstrap.sh"
|
||||
[[ -f "$service_dir/package.json" ]] || fail "$service_name 缺少 package.json: $service_dir"
|
||||
[[ -d "$service_dir/node_modules" ]] || fail "$service_name 依赖未安装: $service_dir/node_modules"
|
||||
}
|
||||
|
||||
|
||||
wait_for_url() {
|
||||
local service_name="$1"
|
||||
local pid_file="$2"
|
||||
local url="$3"
|
||||
local timeout_seconds="${4:-30}"
|
||||
|
||||
local elapsed=0
|
||||
while (( elapsed < timeout_seconds )); do
|
||||
if python3 - "$url" <<'PY'
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
url = sys.argv[1]
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as response:
|
||||
status = getattr(response, "status", 200)
|
||||
raise SystemExit(0 if status < 500 else 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SystemExit(0 if exc.code < 500 else 1)
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! active_pid_from_file "$pid_file" >/dev/null 2>&1; then
|
||||
fail "$service_name 在就绪前已退出,请检查日志"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
fail "$service_name 在 ${timeout_seconds}s 内未就绪: $url"
|
||||
}
|
||||
|
||||
|
||||
stop_service_process() {
|
||||
local service_name="$1"
|
||||
local pid_file="$2"
|
||||
|
||||
if ! pid="$(active_pid_from_file "$pid_file" 2>/dev/null)"; then
|
||||
log "$service_name 未运行"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "停止 $service_name (PID $pid)"
|
||||
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
|
||||
|
||||
local attempt=0
|
||||
while (( attempt < 10 )); do
|
||||
if ! is_pid_running "$pid"; then
|
||||
rm -f "$pid_file"
|
||||
log "$service_name 已停止"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
|
||||
rm -f "$pid_file"
|
||||
log "$service_name 已强制停止"
|
||||
}
|
||||
|
||||
|
||||
clone_or_prepare_repo() {
|
||||
local service_name="$1"
|
||||
local repo_url="$2"
|
||||
local ref="$3"
|
||||
local service_dir="$4"
|
||||
|
||||
mkdir -p "$(dirname "$service_dir")"
|
||||
|
||||
if [[ -d "$service_dir/.git" ]]; then
|
||||
local current_ref
|
||||
current_ref="$(git -C "$service_dir" rev-parse HEAD)"
|
||||
if [[ "$current_ref" == "$ref" ]]; then
|
||||
log "$service_name 源码已存在,revision 已匹配: $current_ref"
|
||||
else
|
||||
log "$service_name 源码已存在,保留当前工作区: $service_dir"
|
||||
log "$service_name 当前 revision: $current_ref"
|
||||
log "$service_name 锁定 revision: $ref"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -e "$service_dir" ]]; then
|
||||
fail "$service_name 目标路径已存在但不是 git 仓库: $service_dir"
|
||||
fi
|
||||
|
||||
log "拉取 $service_name 源码到 $service_dir"
|
||||
git clone "$repo_url" "$service_dir"
|
||||
git -C "$service_dir" checkout "$ref"
|
||||
}
|
||||
|
||||
|
||||
prepare_npm_wrapper_dir() {
|
||||
local service_name="$1"
|
||||
local package_name="$2"
|
||||
local package_version="$3"
|
||||
local service_dir="$4"
|
||||
|
||||
mkdir -p "$service_dir"
|
||||
|
||||
cat > "$service_dir/package.json" <<EOF
|
||||
{
|
||||
"name": "music-workshop-${service_name}",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "node -e \\"require('${package_name}/app.js')\\""
|
||||
},
|
||||
"dependencies": {
|
||||
"${package_name}": "${package_version}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
|
||||
install_node_dependencies() {
|
||||
local service_name="$1"
|
||||
local service_dir="$2"
|
||||
|
||||
log "安装 $service_name 依赖"
|
||||
(
|
||||
cd "$service_dir"
|
||||
npm install
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ensure_qq_port_env_support() {
|
||||
local entry_file="$QQ_DIR/app.js"
|
||||
local marker_file="$QQ_DIR/.codex-port-patched"
|
||||
|
||||
[[ -f "$entry_file" ]] || return 0
|
||||
grep -q "process\\.env\\.PORT" "$entry_file" && return 0
|
||||
[[ -f "$marker_file" ]] && return 0
|
||||
|
||||
if grep -q "const PORT = 3200" "$entry_file"; then
|
||||
python3 - "$entry_file" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
entry = Path(sys.argv[1])
|
||||
source = entry.read_text()
|
||||
updated = source.replace(
|
||||
"const PORT = 3200",
|
||||
"const PORT = parseInt(process.env.PORT || '3200', 10)",
|
||||
1,
|
||||
)
|
||||
|
||||
if source != updated:
|
||||
entry.write_text(updated)
|
||||
PY
|
||||
touch "$marker_file"
|
||||
log "已为 QQ API 注入 PORT 环境变量支持"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
ensure_qq_search_compat_route() {
|
||||
local context_dir="$QQ_DIR/routers/context"
|
||||
local compat_file="$context_dir/compatSearch.js"
|
||||
local router_file="$QQ_DIR/routers/router.js"
|
||||
local index_file="$context_dir/index.js"
|
||||
|
||||
[[ -d "$context_dir" ]] || return 0
|
||||
[[ -f "$router_file" ]] || return 0
|
||||
[[ -f "$index_file" ]] || return 0
|
||||
|
||||
cat > "$compat_file" <<'EOF'
|
||||
const axios = require('axios');
|
||||
|
||||
module.exports = async (ctx, next) => {
|
||||
const keywords = (ctx.query.keywords || ctx.query.keyword || '').trim();
|
||||
const limit = Math.max(1, Number(ctx.query.limit) || 10);
|
||||
const page = Math.max(1, Number(ctx.query.page) || 1);
|
||||
|
||||
if (!keywords) {
|
||||
ctx.status = 400;
|
||||
ctx.body = {
|
||||
error: 'keywords is required',
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://u.y.qq.com/cgi-bin/musicu.fcg',
|
||||
{
|
||||
comm: {
|
||||
ct: '19',
|
||||
cv: '1859',
|
||||
uin: '0',
|
||||
},
|
||||
req: {
|
||||
method: 'DoSearchForQQMusicDesktop',
|
||||
module: 'music.search.SearchCgiService',
|
||||
param: {
|
||||
grp: 1,
|
||||
num_per_page: limit,
|
||||
page_num: page,
|
||||
query: keywords,
|
||||
search_type: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.8,en;q=0.5',
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const songPayload = (((response.data || {}).req || {}).data || {}).body || {};
|
||||
ctx.status = 200;
|
||||
ctx.body = {
|
||||
data: {
|
||||
song: songPayload.song || {
|
||||
list: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.status = error.response?.status || 502;
|
||||
ctx.body = {
|
||||
error: 'qq search failed',
|
||||
detail: error.response?.data || error.message,
|
||||
};
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
EOF
|
||||
|
||||
python3 - "$router_file" "$index_file" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
router_path = Path(sys.argv[1])
|
||||
index_path = Path(sys.argv[2])
|
||||
|
||||
router_source = router_path.read_text()
|
||||
route_line = "router.get('/search', context.compatSearch);\n"
|
||||
route_anchor = "router.get('/getHotkey', context.getHotKey);\n"
|
||||
if route_line not in router_source:
|
||||
if route_anchor not in router_source:
|
||||
raise SystemExit("QQ router anchor not found")
|
||||
router_source = router_source.replace(
|
||||
route_anchor,
|
||||
route_anchor + "\n" + route_line,
|
||||
1,
|
||||
)
|
||||
router_path.write_text(router_source)
|
||||
|
||||
index_source = index_path.read_text()
|
||||
require_line = "const compatSearch = require('./compatSearch');\n"
|
||||
require_anchor = "const getHotKey = require('./getHotkey');\n"
|
||||
if require_line not in index_source:
|
||||
if require_anchor not in index_source:
|
||||
raise SystemExit("QQ context require anchor not found")
|
||||
index_source = index_source.replace(
|
||||
require_anchor,
|
||||
require_anchor + require_line,
|
||||
1,
|
||||
)
|
||||
|
||||
export_line = "\tcompatSearch,\n"
|
||||
export_anchor = "\tgetHotKey,\n"
|
||||
if export_line not in index_source:
|
||||
if export_anchor not in index_source:
|
||||
raise SystemExit("QQ context export anchor not found")
|
||||
index_source = index_source.replace(
|
||||
export_anchor,
|
||||
export_anchor + export_line,
|
||||
1,
|
||||
)
|
||||
|
||||
index_path.write_text(index_source)
|
||||
PY
|
||||
}
|
||||
Reference in New Issue
Block a user