feat: add prebuilt installer workflow

This commit is contained in:
nianzhibai
2026-05-28 19:13:41 +08:00
parent bb8818a55a
commit 137cfbcf82
9 changed files with 1222 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
cache-dependency-path: backend/go.sum
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Build release packages
run: scripts/build-release.sh
- name: Upload release assets
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" release/*.tar.gz --clobber
else
gh release create "$TAG" release/*.tar.gz --title "$TAG" --notes "Prebuilt Linux release packages."
fi
+1
View File
@@ -23,6 +23,7 @@ tools/
# 编译产物
backend/server
release/
tsconfig.tsbuildinfo
# 91 爬虫脚本独立运行时的默认输出文件(backend 跑时会显式 --output 到 backend/data/spider91/,所以不会落在这里)
+62
View File
@@ -71,6 +71,68 @@ npm install
FRONTEND_MODE=dev ./start.sh --restart
```
## 新服务器一键安装
如果你只是想在一台 Ubuntu / Debian 服务器上尽快跑起来,推荐使用预编译安装脚本。普通用户不需要安装 Go、Node.js,也不需要自己编译;脚本会按服务器 CPU 架构下载 GitHub Release 里的预编译包,安装运行依赖,写入 systemd 服务并启动。
```bash
sudo apt update
sudo apt install -y curl ca-certificates
curl -fsSL https://raw.githubusercontent.com/nianzhibai/91/main/install.sh -o install.sh
sudo bash install.sh
```
部署完成后访问:
- 前台:`http://服务器IP:9191/`
- 后台:`http://服务器IP:9191/admin`
第一次打开后台会要求设置管理员用户名和密码。常用维护命令:
```bash
sudo bash install.sh status
sudo bash install.sh logs
sudo bash install.sh update
sudo bash install.sh restart
sudo bash install.sh stop
```
安装后会自动创建 `91` 指令,和 OpenList 的管理指令类似:
```bash
91 # 打开管理菜单
91 status # 查看状态
91 logs # 查看日志
91 update # 更新
91 restart # 重启
91 stop # 停止
```
同时也保留 `video-site-91` 作为同等别名。
想换端口:
```bash
FRONTEND_PORT=8080 sudo -E bash install.sh
```
如果服务器还有云厂商安全组,请记得放行对应端口,默认是 `9191/tcp`
如果你是项目维护者,要预先编译发布包:
```bash
scripts/build-release.sh
```
它会生成:
- `release/video-site-91-linux-amd64.tar.gz`
- `release/video-site-91-linux-arm64.tar.gz`
把这两个文件上传到 GitHub Release 后,`install.sh` 就能自动下载。仓库也带了 GitHub Actions:推送 `v*` 标签时会自动构建并上传这两个 Release 包。
源码部署仍然保留在 `deploy.sh`,适合你想在服务器上直接 clone、编译和调试时使用。
## 第一次使用
1. 打开 `http://127.0.0.1:9191/`,先完成管理员账号设置。
+8
View File
@@ -165,6 +165,14 @@ go test ./... -count=1
## 部署到 Linux
推荐先使用根目录的预编译安装脚本:
```bash
sudo bash install.sh
```
它会从 GitHub Release 下载预编译包,安装运行依赖、写入 systemd 服务并启动。下面是手动部署方式,适合你想自己接管构建和服务管理时使用。
```bash
# 交叉编译
GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFrontendHandlerServesStaticAsset(t *testing.T) {
dir := t.TempDir()
assets := filepath.Join(dir, "assets")
if err := os.MkdirAll(assets, 0o755); err != nil {
t.Fatalf("mkdir assets: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html>app</html>"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
if err := os.WriteFile(filepath.Join(assets, "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
t.Fatalf("write asset: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
rr := httptest.NewRecorder()
frontendHandler(dir).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "console.log") {
t.Fatalf("body = %q, want asset content", rr.Body.String())
}
}
func TestFrontendHandlerFallsBackToIndexForSPARoute(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html>app</html>"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
rr := httptest.NewRecorder()
frontendHandler(dir).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if rr.Body.String() != "<html>app</html>" {
t.Fatalf("body = %q, want index", rr.Body.String())
}
}
func TestFrontendHandlerDoesNotSwallowBackendRoutes(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html>app</html>"), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
for _, target := range []string{"/api/missing", "/admin/api/missing", "/p/missing"} {
req := httptest.NewRequest(http.MethodGet, target, nil)
rr := httptest.NewRecorder()
frontendHandler(dir).ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("%s status = %d, want 404", target, rr.Code)
}
}
}
+61
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"path"
"path/filepath"
"strconv"
"strings"
@@ -212,6 +213,7 @@ func main() {
apiServer.RegisterRoutes(r, authr)
adminServer.Register(r)
mountFrontend(r)
// 凌晨流水线:每天 cron_hour 触发一次,串行跑
// Phase 1 扫所有非 spider91 / localupload 网盘 + 删除检测 + 入队封面/teaser
@@ -1441,6 +1443,65 @@ func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
}
}
func mountFrontend(r chi.Router) {
dir := strings.TrimSpace(os.Getenv("VIDEO_FRONTEND_DIR"))
if dir == "" {
dir = "./dist"
}
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return
}
indexPath := filepath.Join(dir, "index.html")
if st, err := os.Stat(indexPath); err != nil || st.IsDir() {
return
}
log.Printf("serving frontend from %s", dir)
r.NotFound(frontendHandler(dir))
}
func frontendHandler(dir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
if isBackendRoute(r.URL.Path) {
http.NotFound(w, r)
return
}
cleanPath := path.Clean("/" + r.URL.Path)
rel := strings.TrimPrefix(cleanPath, "/")
if rel != "" && rel != "." {
name := filepath.FromSlash(rel)
f, err := os.Open(filepath.Join(dir, name))
if err == nil {
defer f.Close()
if st, statErr := f.Stat(); statErr == nil && !st.IsDir() {
http.ServeContent(w, r, st.Name(), st.ModTime(), f)
return
}
}
if filepath.Ext(name) != "" {
http.NotFound(w, r)
return
}
}
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
}
}
func isBackendRoute(p string) bool {
return p == "/api" ||
strings.HasPrefix(p, "/api/") ||
p == "/admin/api" ||
strings.HasPrefix(p, "/admin/api/") ||
p == "/p" ||
strings.HasPrefix(p, "/p/")
}
func parseBoolDefault(raw string, def bool) bool {
if raw == "" {
return def
Executable
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SELF_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_NAME="${APP_NAME:-video-site-91}"
BACKEND_SERVICE="${BACKEND_SERVICE:-video-site-backend}"
FRONTEND_SERVICE="${FRONTEND_SERVICE:-video-site-frontend}"
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
FRONTEND_PORT="${FRONTEND_PORT:-9191}"
BACKEND_LISTEN="${BACKEND_LISTEN:-127.0.0.1:9192}"
GO_VERSION="${GO_VERSION:-1.23.12}"
INSTALL_DEPS="${INSTALL_DEPS:-1}"
CONFIGURE_UFW="${CONFIGURE_UFW:-1}"
export PATH="/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
log() {
printf '\033[1;34m[deploy]\033[0m %s\n' "$*"
}
warn() {
printf '\033[1;33m[deploy]\033[0m %s\n' "$*" >&2
}
die() {
printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2
exit 1
}
usage() {
cat <<EOF
Usage: sudo bash deploy.sh [install|update|restart|stop|status|logs|uninstall]
Default action:
install Install dependencies, build, create systemd services, and start.
Actions:
install First deployment or full repair
update Rebuild current code and restart services
restart Restart systemd services
stop Stop systemd services
status Show service status
logs Follow backend and frontend logs
uninstall Remove systemd services only; keep repo, config, and data
Common overrides:
FRONTEND_PORT=9191 Public web port
FRONTEND_HOST=0.0.0.0 Public web bind address
GO_VERSION=1.23.12
INSTALL_DEPS=0 Do not install missing Node/Go/ffmpeg
CONFIGURE_UFW=0 Do not open UFW port automatically
DEPLOY_USER=<user> Service user; defaults to sudo user or root
Examples:
sudo bash deploy.sh
FRONTEND_PORT=8080 sudo -E bash deploy.sh
sudo bash deploy.sh update
sudo bash deploy.sh logs
EOF
}
need_root() {
if [[ "${EUID}" -eq 0 ]]; then
return
fi
if ! command -v sudo >/dev/null 2>&1; then
die "this action needs root. Re-run as root or install sudo."
fi
log "root permission required; re-running with sudo"
exec sudo -E bash "$SELF_PATH" "$@"
}
detect_deploy_user() {
DEPLOY_USER="${DEPLOY_USER:-${SUDO_USER:-$(id -un)}}"
if [[ "$REPO_DIR" == /root/* && "$DEPLOY_USER" != "root" ]]; then
warn "repo is under /root; using root as service user so systemd can read it"
DEPLOY_USER="root"
fi
if ! id "$DEPLOY_USER" >/dev/null 2>&1; then
die "DEPLOY_USER does not exist: $DEPLOY_USER"
fi
DEPLOY_GROUP="${DEPLOY_GROUP:-$(id -gn "$DEPLOY_USER")}"
DEPLOY_HOME="$(getent passwd "$DEPLOY_USER" | cut -d: -f6)"
if [[ -z "$DEPLOY_HOME" ]]; then
DEPLOY_HOME="/root"
fi
}
as_deploy_user() {
if [[ "$DEPLOY_USER" == "root" ]]; then
HOME="$DEPLOY_HOME" PATH="$PATH" "$@"
return
fi
runuser -u "$DEPLOY_USER" -- env HOME="$DEPLOY_HOME" PATH="$PATH" "$@"
}
require_repo() {
[[ -f "$REPO_DIR/package.json" ]] || die "package.json not found; run this script from the project root"
[[ -d "$REPO_DIR/backend" ]] || die "backend directory not found; run this script from the project root"
}
version_ge() {
[[ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" == "$2" ]]
}
node_ok() {
command -v node >/dev/null 2>&1 || return 1
command -v npm >/dev/null 2>&1 || return 1
local major
major="$(node -v | sed -E 's/^v([0-9]+).*/\1/')"
[[ "$major" =~ ^[0-9]+$ ]] && (( major >= 18 ))
}
go_ok() {
command -v go >/dev/null 2>&1 || return 1
local version
version="$(go env GOVERSION 2>/dev/null || true)"
if [[ -z "$version" ]]; then
version="$(go version | awk '{print $3}')"
fi
version="${version#go}"
version_ge "$version" "1.23"
}
apt_install() {
[[ "$INSTALL_DEPS" == "1" ]] || die "missing dependencies and INSTALL_DEPS=0"
command -v apt-get >/dev/null 2>&1 || die "automatic install currently supports Debian/Ubuntu with apt-get"
export DEBIAN_FRONTEND=noninteractive
log "installing base packages"
apt-get update
apt-get install -y ca-certificates curl git ffmpeg openssl iproute2 build-essential
}
install_node() {
if node_ok; then
log "Node $(node -v) and npm $(npm -v) are ready"
return
fi
[[ "$INSTALL_DEPS" == "1" ]] || die "Node.js 18+ and npm are required"
command -v apt-get >/dev/null 2>&1 || die "install Node.js 18+ manually, then re-run"
log "installing Node.js 20 from NodeSource"
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/video-site-nodesource.sh
bash /tmp/video-site-nodesource.sh
apt-get install -y nodejs
node_ok || die "Node.js install finished, but node/npm version check still failed"
log "Node $(node -v) and npm $(npm -v) are ready"
}
install_go() {
if go_ok; then
log "Go $(go env GOVERSION 2>/dev/null || go version | awk '{print $3}') is ready"
return
fi
[[ "$INSTALL_DEPS" == "1" ]] || die "Go 1.23+ is required"
local arch go_arch tmp url
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) go_arch="amd64" ;;
aarch64|arm64) go_arch="arm64" ;;
*) die "unsupported CPU architecture for automatic Go install: $arch" ;;
esac
url="https://go.dev/dl/go${GO_VERSION}.linux-${go_arch}.tar.gz"
tmp="$(mktemp -d)"
log "installing Go ${GO_VERSION} from ${url}"
curl -fL "$url" -o "$tmp/go.tgz"
rm -rf /usr/local/go
tar -C /usr/local -xzf "$tmp/go.tgz"
rm -rf "$tmp"
go_ok || die "Go install finished, but go version check still failed"
log "Go $(go env GOVERSION) is ready"
}
install_dependencies() {
if [[ "$INSTALL_DEPS" == "1" ]]; then
apt_install
fi
install_node
install_go
command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg is required"
command -v ffprobe >/dev/null 2>&1 || die "ffprobe is required"
}
ensure_ownership() {
local paths=()
[[ -e "$REPO_DIR/backend/config.yaml" ]] && paths+=("$REPO_DIR/backend/config.yaml")
[[ -d "$REPO_DIR/backend/data" ]] && paths+=("$REPO_DIR/backend/data")
[[ -d "$REPO_DIR/dist" ]] && paths+=("$REPO_DIR/dist")
[[ -d "$REPO_DIR/node_modules" ]] && paths+=("$REPO_DIR/node_modules")
[[ -e "$REPO_DIR/backend/server" ]] && paths+=("$REPO_DIR/backend/server")
if (( ${#paths[@]} > 0 )); then
chown -R "$DEPLOY_USER:$DEPLOY_GROUP" "${paths[@]}"
fi
}
prepare_config() {
local cfg="$REPO_DIR/backend/config.yaml"
local example="$REPO_DIR/backend/config.example.yaml"
mkdir -p "$REPO_DIR/backend/data"
if [[ ! -f "$cfg" ]]; then
log "creating backend/config.yaml from example"
cp "$example" "$cfg"
sed -i -E "s#listen: \".*\"#listen: \"$BACKEND_LISTEN\"#" "$cfg"
else
log "backend/config.yaml already exists; keeping it"
fi
if grep -q 'session_secret: "change-me-to-a-random-string"' "$cfg"; then
local secret
secret="$(openssl rand -hex 32)"
sed -i -E "s#session_secret: \".*\"#session_secret: \"$secret\"#" "$cfg"
log "generated a random session_secret"
fi
ensure_ownership
}
install_frontend() {
log "installing frontend dependencies"
if [[ -f "$REPO_DIR/package-lock.json" ]]; then
as_deploy_user bash -lc "cd '$REPO_DIR' && npm ci"
else
as_deploy_user bash -lc "cd '$REPO_DIR' && npm install"
fi
log "building frontend"
as_deploy_user bash -lc "cd '$REPO_DIR' && npm run build"
}
build_backend() {
log "building backend binary"
as_deploy_user bash -lc "cd '$REPO_DIR/backend' && go build -o server ./cmd/server"
}
systemd_env_lines() {
local lines=""
if [[ -n "${HTTP_PROXY:-}" ]]; then
lines+="Environment=HTTP_PROXY=${HTTP_PROXY}"$'\n'
fi
if [[ -n "${HTTPS_PROXY:-}" ]]; then
lines+="Environment=HTTPS_PROXY=${HTTPS_PROXY}"$'\n'
fi
if [[ -n "${NO_PROXY:-}" ]]; then
lines+="Environment=NO_PROXY=${NO_PROXY}"$'\n'
fi
printf '%s' "$lines"
}
write_systemd_units() {
local npm_bin backend_unit frontend_unit env_lines
npm_bin="$(command -v npm)"
backend_unit="/etc/systemd/system/${BACKEND_SERVICE}.service"
frontend_unit="/etc/systemd/system/${FRONTEND_SERVICE}.service"
env_lines="$(systemd_env_lines)"
log "writing systemd unit: $backend_unit"
cat >"$backend_unit" <<EOF
[Unit]
Description=Video Site Backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${DEPLOY_USER}
Group=${DEPLOY_GROUP}
WorkingDirectory=${REPO_DIR}/backend
ExecStart=${REPO_DIR}/backend/server
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
Environment=HOME=${DEPLOY_HOME}
Environment=PATH=/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
${env_lines}LimitNOFILE=65536
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${BACKEND_SERVICE}
[Install]
WantedBy=multi-user.target
EOF
log "writing systemd unit: $frontend_unit"
cat >"$frontend_unit" <<EOF
[Unit]
Description=Video Site Frontend
After=network-online.target ${BACKEND_SERVICE}.service
Wants=network-online.target
[Service]
Type=simple
User=${DEPLOY_USER}
Group=${DEPLOY_GROUP}
WorkingDirectory=${REPO_DIR}
ExecStart=${npm_bin} run preview -- --host ${FRONTEND_HOST} --port ${FRONTEND_PORT}
Restart=on-failure
RestartSec=5
Environment=HOME=${DEPLOY_HOME}
Environment=NODE_ENV=production
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${FRONTEND_SERVICE}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${BACKEND_SERVICE}.service" "${FRONTEND_SERVICE}.service" >/dev/null
}
open_firewall_port() {
[[ "$CONFIGURE_UFW" == "1" ]] || return
command -v ufw >/dev/null 2>&1 || return
if ufw status 2>/dev/null | grep -qi "Status: active"; then
log "UFW is active; allowing ${FRONTEND_PORT}/tcp"
ufw allow "${FRONTEND_PORT}/tcp"
fi
}
restart_services() {
log "starting services"
systemctl restart "${BACKEND_SERVICE}.service"
systemctl restart "${FRONTEND_SERVICE}.service"
}
show_summary() {
echo
log "deployment finished"
echo " frontend: http://<server-ip>:${FRONTEND_PORT}/"
echo " admin: http://<server-ip>:${FRONTEND_PORT}/admin"
echo " backend: 127.0.0.1:9192"
echo
echo "First visit will ask you to create the admin username and password."
echo "Useful commands:"
echo " sudo bash deploy.sh status"
echo " sudo bash deploy.sh logs"
echo " sudo bash deploy.sh update"
}
show_status() {
systemctl --no-pager --full status "${BACKEND_SERVICE}.service" "${FRONTEND_SERVICE}.service" || true
}
install_or_update() {
local mode="$1"
require_repo
detect_deploy_user
install_dependencies
prepare_config
install_frontend
build_backend
write_systemd_units
open_firewall_port
restart_services
show_status
[[ "$mode" == "install" ]] && show_summary
}
uninstall_services() {
systemctl disable --now "${FRONTEND_SERVICE}.service" "${BACKEND_SERVICE}.service" 2>/dev/null || true
rm -f "/etc/systemd/system/${FRONTEND_SERVICE}.service" "/etc/systemd/system/${BACKEND_SERVICE}.service"
systemctl daemon-reload
log "removed systemd services; repo, config, and data were kept"
}
main() {
local action="${1:-install}"
case "$action" in
install|deploy)
need_root "$@"
install_or_update "install"
;;
update)
need_root "$@"
install_or_update "update"
;;
restart)
need_root "$@"
restart_services
show_status
;;
stop)
need_root "$@"
systemctl stop "${FRONTEND_SERVICE}.service" "${BACKEND_SERVICE}.service"
;;
status)
show_status
;;
logs)
journalctl -u "${BACKEND_SERVICE}.service" -u "${FRONTEND_SERVICE}.service" -f
;;
uninstall)
need_root "$@"
uninstall_services
;;
-h|--help|help)
usage
;;
*)
usage >&2
exit 2
;;
esac
}
main "$@"
Executable
+462
View File
@@ -0,0 +1,462 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="${APP_NAME:-video-site-91}"
GITHUB_REPO="${GITHUB_REPO:-nianzhibai/91}"
INSTALL_PATH="${INSTALL_PATH:-/opt/video-site-91}"
SERVICE_NAME="${SERVICE_NAME:-video-site-91}"
FRONTEND_PORT_WAS_SET="${FRONTEND_PORT+x}"
FRONTEND_PORT="${FRONTEND_PORT:-9191}"
VERSION="${VERSION:-latest}"
GH_PROXY="${GH_PROXY:-}"
CONFIGURE_UFW="${CONFIGURE_UFW:-1}"
INSTALL_DEPS="${INSTALL_DEPS:-1}"
VERSION_FILE="$INSTALL_PATH/.version"
MANAGER_PATH="/usr/local/sbin/${APP_NAME}-manager"
COMMAND_LINK="/usr/local/bin/91"
APP_COMMAND_LINK="/usr/local/bin/${APP_NAME}"
RED='\033[1;31m'
GREEN='\033[1;32m'
YELLOW='\033[1;33m'
BLUE='\033[1;34m'
RESET='\033[0m'
log() {
printf "${BLUE}[install]${RESET} %s\n" "$*"
}
warn() {
printf "${YELLOW}[install]${RESET} %s\n" "$*" >&2
}
die() {
printf "${RED}[install]${RESET} %s\n" "$*" >&2
exit 1
}
usage() {
cat <<EOF
Usage:
sudo bash install.sh [install]
91 [update|restart|stop|status|logs|uninstall]
Default action:
install.sh with no args downloads the prebuilt release package and starts the service.
91 with no args opens the management menu.
Actions:
install Install to $INSTALL_PATH
update Download latest release and replace program files, keeping config/data
restart Restart service
stop Stop service
status Show service status
logs Follow service logs
uninstall Remove service and optionally delete installed files
Options via environment:
GITHUB_REPO=$GITHUB_REPO
VERSION=$VERSION latest or a release tag such as v0.1.0
INSTALL_PATH=$INSTALL_PATH
FRONTEND_PORT=$FRONTEND_PORT
GH_PROXY=$GH_PROXY
INSTALL_DEPS=$INSTALL_DEPS
CONFIGURE_UFW=$CONFIGURE_UFW
Examples:
sudo bash install.sh
FRONTEND_PORT=8080 sudo -E bash install.sh
91
91 update
91 logs
EOF
}
is_manager_invocation() {
local name
name="$(basename "$0")"
[[ "$name" == "91" || "$name" == "$APP_NAME" || "$name" == "$(basename "$MANAGER_PATH")" ]]
}
need_root() {
if [[ "$(id -u)" == "0" ]]; then
return
fi
if command -v sudo >/dev/null 2>&1; then
exec sudo -E bash "$0" "$@"
fi
die "please run as root"
}
detect_arch() {
local machine
machine="$(uname -m)"
case "$machine" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) die "unsupported architecture: $machine" ;;
esac
}
download_base_url() {
if [[ "$VERSION" == "latest" ]]; then
printf '%shttps://github.com/%s/releases/latest/download' "$GH_PROXY" "$GITHUB_REPO"
else
printf '%shttps://github.com/%s/releases/download/%s' "$GH_PROXY" "$GITHUB_REPO" "$VERSION"
fi
}
asset_name() {
printf '%s-linux-%s.tar.gz' "$APP_NAME" "$ARCH"
}
install_deps() {
if [[ "$INSTALL_DEPS" != "1" ]]; then
return
fi
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
log "installing runtime dependencies"
apt-get update
apt-get install -y ca-certificates curl tar ffmpeg openssl iproute2 python3 python3-requests python3-bs4 python3-lxml
return
fi
for cmd in curl tar ffmpeg ffprobe openssl; do
command -v "$cmd" >/dev/null 2>&1 || die "missing command: $cmd"
done
}
check_system() {
[[ "$(uname -s)" == "Linux" ]] || die "Linux is required"
command -v systemctl >/dev/null 2>&1 || die "systemd is required"
detect_arch
}
check_disk_space() {
local parent avail
parent="$(dirname "$INSTALL_PATH")"
mkdir -p "$parent"
avail="$(df -Pm "$parent" | awk 'NR==2 {print $4}')"
if [[ "$avail" =~ ^[0-9]+$ ]] && (( avail < 512 )); then
die "not enough free space under $parent, need at least 512 MB"
fi
}
download_file() {
local url="$1"
local output="$2"
local retry=0
while (( retry < 3 )); do
if curl -fL --connect-timeout 15 --retry 2 --retry-delay 2 "$url" -o "$output"; then
[[ -s "$output" ]] && return 0
fi
retry=$((retry + 1))
warn "download failed, retry $retry/3"
sleep $((retry * 2))
done
return 1
}
prepare_config() {
local cfg="$INSTALL_PATH/config.yaml"
local example="$INSTALL_PATH/config.example.yaml"
mkdir -p "$INSTALL_PATH/data"
if [[ ! -f "$cfg" ]]; then
cp "$example" "$cfg"
sed -i -E "s#listen: \".*\"#listen: \"0.0.0.0:${FRONTEND_PORT}\"#" "$cfg"
chmod 600 "$cfg"
log "created $cfg"
else
log "keeping existing $cfg"
if [[ -n "$FRONTEND_PORT_WAS_SET" ]]; then
sed -i -E "s#listen: \".*\"#listen: \"0.0.0.0:${FRONTEND_PORT}\"#" "$cfg"
log "updated listen port to ${FRONTEND_PORT}"
fi
fi
if grep -q 'session_secret: "change-me-to-a-random-string"' "$cfg"; then
local secret
secret="$(openssl rand -hex 32)"
sed -i -E "s#session_secret: \".*\"#session_secret: \"$secret\"#" "$cfg"
log "generated random session_secret"
fi
}
write_service() {
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=Video Site 91
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${INSTALL_PATH}
ExecStart=${INSTALL_PATH}/server
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
Environment=VIDEO_CONFIG=${INSTALL_PATH}/config.yaml
Environment=VIDEO_FRONTEND_DIR=${INSTALL_PATH}/dist
Environment=HOME=/root
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service" >/dev/null
}
install_cli() {
local src
src="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
if [[ -f "$src" ]]; then
cp "$src" "$MANAGER_PATH"
chmod 755 "$MANAGER_PATH"
ln -sf "$MANAGER_PATH" "$COMMAND_LINK"
ln -sf "$MANAGER_PATH" "$APP_COMMAND_LINK"
fi
}
open_firewall_port() {
[[ "$CONFIGURE_UFW" == "1" ]] || return
command -v ufw >/dev/null 2>&1 || return
if ufw status 2>/dev/null | grep -qi "Status: active"; then
log "allowing ${FRONTEND_PORT}/tcp in UFW"
ufw allow "${FRONTEND_PORT}/tcp"
fi
}
fetch_and_unpack() {
local tmp archive url root
tmp="$(mktemp -d)"
archive="$tmp/$(asset_name)"
url="$(download_base_url)/$(asset_name)"
log "downloading $url"
if ! download_file "$url" "$archive"; then
warn "download failed: $url"
rm -rf "$tmp"
return 1
fi
if ! tar -xzf "$archive" -C "$tmp"; then
warn "extract failed"
rm -rf "$tmp"
return 1
fi
root="$tmp/${APP_NAME}-linux-${ARCH}"
if [[ ! -f "$root/server" || ! -d "$root/dist" || ! -f "$root/config.example.yaml" ]]; then
warn "release package layout is invalid"
rm -rf "$tmp"
return 1
fi
mkdir -p "$INSTALL_PATH"
cp "$root/server" "$INSTALL_PATH/server"
rm -rf "$INSTALL_PATH/dist"
cp -R "$root/dist" "$INSTALL_PATH/dist"
cp "$root/config.example.yaml" "$INSTALL_PATH/config.example.yaml"
if [[ -d "$root/91VideoSpider" ]]; then
rm -rf "$INSTALL_PATH/91VideoSpider"
cp -R "$root/91VideoSpider" "$INSTALL_PATH/91VideoSpider"
fi
chmod +x "$INSTALL_PATH/server"
rm -rf "$tmp"
}
current_version_from_github() {
if [[ "$VERSION" != "latest" ]]; then
printf '%s' "$VERSION"
return
fi
curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' \
| head -n1
}
record_version() {
local version
version="$(current_version_from_github || true)"
[[ -n "$version" ]] || version="$VERSION"
{
echo "$version"
date '+%Y-%m-%d %H:%M:%S'
} >"$VERSION_FILE"
}
show_success() {
local local_ip public_ip version
local_ip="$(ip addr show 2>/dev/null | awk '/inet / && $2 !~ /^127/ {sub(/\/.*/, "", $2); print $2; exit}')"
public_ip="$(curl -s4 --connect-timeout 5 ip.sb 2>/dev/null || true)"
version="$(head -n1 "$VERSION_FILE" 2>/dev/null || echo unknown)"
echo
printf "${GREEN}安装完成${RESET}\n"
echo "版本:$version"
[[ -n "$local_ip" ]] && echo "局域网:http://${local_ip}:${FRONTEND_PORT}/"
[[ -n "$public_ip" ]] && echo "公网: http://${public_ip}:${FRONTEND_PORT}/"
echo "后台: http://服务器IP:${FRONTEND_PORT}/admin"
echo "数据: $INSTALL_PATH/data"
echo
echo "首次访问后台时会要求设置管理员用户名和密码。"
echo "管理命令:91 或 91 status | logs | update | restart | stop"
}
install_app() {
check_system
check_disk_space
install_deps
systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
fetch_and_unpack || die "install failed"
prepare_config
write_service
install_cli
open_firewall_port
record_version
systemctl restart "${SERVICE_NAME}.service"
show_success
}
update_app() {
check_system
check_disk_space
install_deps
[[ -f "$INSTALL_PATH/server" ]] || die "not installed at $INSTALL_PATH"
local backup
backup="$(mktemp -d)"
cp "$INSTALL_PATH/server" "$backup/server"
[[ -d "$INSTALL_PATH/dist" ]] && cp -R "$INSTALL_PATH/dist" "$backup/dist"
systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
if ! fetch_and_unpack; then
warn "update failed; restoring previous files"
cp "$backup/server" "$INSTALL_PATH/server"
rm -rf "$INSTALL_PATH/dist"
[[ -d "$backup/dist" ]] && cp -R "$backup/dist" "$INSTALL_PATH/dist"
systemctl start "${SERVICE_NAME}.service" 2>/dev/null || true
rm -rf "$backup"
exit 1
fi
prepare_config
write_service
install_cli
record_version
systemctl restart "${SERVICE_NAME}.service"
rm -rf "$backup"
log "updated"
}
uninstall_app() {
systemctl disable --now "${SERVICE_NAME}.service" 2>/dev/null || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
rm -f "$COMMAND_LINK" "$APP_COMMAND_LINK" "$MANAGER_PATH"
if [[ -t 0 ]]; then
read -r -p "删除 $INSTALL_PATH 里的程序、配置和数据吗?[y/N]: " confirm
case "$confirm" in
[yY]) rm -rf "$INSTALL_PATH" ;;
*) log "kept $INSTALL_PATH" ;;
esac
else
log "removed service; kept $INSTALL_PATH"
fi
}
show_menu() {
if [[ ! -t 0 ]]; then
usage
return 0
fi
while true; do
clear
echo "欢迎使用 91 管理脚本"
echo
echo "基础功能:"
echo "1、查看状态"
echo "2、查看日志"
echo "3、更新 91"
echo "4、重启 91"
echo "5、停止 91"
echo "6、卸载 91"
echo "0、退出"
echo
read -r -p "请输入选项 [0-6]: " choice
case "$choice" in
1) main status ;;
2) main logs ;;
3) main update ;;
4) main restart ;;
5) main stop ;;
6) main uninstall ;;
0) exit 0 ;;
*) echo "无效的选项" ;;
esac
echo
read -r -n1 -s -p "按任意键继续 ..."
done
}
main() {
local action="${1:-}"
if [[ -z "$action" ]]; then
if is_manager_invocation; then
show_menu
return
fi
action="install"
fi
case "$action" in
install)
need_root "$@"
install_app
;;
update)
need_root "$@"
update_app
;;
restart)
need_root "$@"
systemctl restart "${SERVICE_NAME}.service"
;;
stop)
need_root "$@"
systemctl stop "${SERVICE_NAME}.service"
;;
status)
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
;;
logs)
journalctl -u "${SERVICE_NAME}.service" -f
;;
menu)
show_menu
;;
uninstall)
need_root "$@"
uninstall_app
;;
-h|--help|help)
usage
;;
*)
usage >&2
exit 2
;;
esac
}
main "$@"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/release}"
APP_NAME="${APP_NAME:-video-site-91}"
VERSION="${VERSION:-$(git -C "$ROOT_DIR" describe --tags --always --dirty 2>/dev/null || date +%Y%m%d%H%M%S)}"
log() {
printf '[release] %s\n' "$*"
}
usage() {
cat <<EOF
Usage: scripts/build-release.sh
Builds precompiled release packages:
release/video-site-91-linux-amd64.tar.gz
release/video-site-91-linux-arm64.tar.gz
Environment overrides:
OUT_DIR=$OUT_DIR
APP_NAME=$APP_NAME
VERSION=$VERSION
EOF
}
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
build_frontend() {
need_cmd npm
log "installing frontend dependencies"
if [[ -f "$ROOT_DIR/package-lock.json" ]]; then
npm --prefix "$ROOT_DIR" ci
else
npm --prefix "$ROOT_DIR" install
fi
log "building frontend"
npm --prefix "$ROOT_DIR" run build
}
build_package() {
local goos="$1"
local goarch="$2"
local artifact="$APP_NAME-$goos-$goarch"
local work="$OUT_DIR/.work/$artifact"
rm -rf "$work"
mkdir -p "$work"
log "building backend for $goos/$goarch"
(
cd "$ROOT_DIR/backend"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$work/server" ./cmd/server
)
cp "$ROOT_DIR/backend/config.example.yaml" "$work/config.example.yaml"
cp -R "$ROOT_DIR/dist" "$work/dist"
mkdir -p "$work/91VideoSpider"
cp "$ROOT_DIR/91VideoSpider/spider_91porn.py" "$work/91VideoSpider/spider_91porn.py"
cat >"$work/README.txt" <<EOF
$APP_NAME $VERSION
This is a prebuilt release package.
Use install.sh from the repository to install it on a Linux server.
EOF
chmod +x "$work/server"
tar -C "$OUT_DIR/.work" -czf "$OUT_DIR/$artifact.tar.gz" "$artifact"
log "wrote $OUT_DIR/$artifact.tar.gz"
}
main() {
case "${1:-}" in
-h|--help|help)
usage
exit 0
;;
"")
;;
*)
usage >&2
exit 2
;;
esac
need_cmd go
need_cmd tar
mkdir -p "$OUT_DIR/.work"
build_frontend
build_package linux amd64
build_package linux arm64
rm -rf "$OUT_DIR/.work"
log "done"
}
main "$@"