Files
MusicWorkshop/services/metadata/scripts/common.sh
T
2026-04-30 14:34:28 +08:00

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
}