437 lines
9.7 KiB
Bash
Executable File
437 lines
9.7 KiB
Bash
Executable File
#!/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
|
|
}
|