feat: add OpenList upload support and upload tasks monitoring page

Backend:
- Add OpenListRecordArtifactUploader implementing AList-compatible API upload
- Add Uploading status to RecordArtifactUploadStatus enum
- Add MarkUploadStarted() to RecordResult entity for upload progress tracking
- Add ListUploadStatus API endpoint with pagination and status filtering
- Add UploadTaskItemDto and UploadTaskListResponse models
- Add upload segment count stats (uploaded/failed/uploading) to session DTO
- Add OpenListUploadSettingsDto and upload target type OpenList

Frontend:
- Add UploadTasksView page with route /upload-tasks
- Add upload status labels and UploadTaskItem types
- Refactor MainLayout navigation and clean up main.css
- Polish DashboardView, MetricCard, StatusBadge, RightDrawer components
- Update SettingsView to support OpenList upload configuration

Build:
- Add frontend/Dockerfile.arm64 for ARM64 frontend image
- Update build-arm64-image.sh script

Other:
- Add segment_completed_openlist.sh trigger script
- Add prototype/ directory with UI mockups
- Add frontend .dockerignore refinements

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:14:50 +08:00
co-authored by Claude Fable 5
parent f8abae0a7a
commit 9df174bb0b
44 changed files with 3962 additions and 1827 deletions
+53 -29
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
#
# Build the LiveRecorder WebApi image for linux/arm64 and export it as a
# gzip-compressed `docker load`-able archive.
# Build both the LiveRecorder WebApi and Frontend (nginx) images for
# linux/arm64 and export them as a single gzip-compressed `docker load`-able
# archive.
#
# The docker-compose.yml uses a split architecture:
# nginx (frontend) — Vue SPA served by nginx, proxies /api/* to api:8080
# api (backend) — .NET 8 WebApi
#
# Both images must be ARM64 for a full deployment on an ARM64 host.
#
# Target environment: Windows + Docker Desktop (WSL2 backend) + a host HTTP proxy.
# It encodes the workarounds discovered while building from a network where the
@@ -18,8 +25,9 @@
# scripts/build-arm64-image.sh
#
# Override defaults via env vars:
# PROXY=http://127.0.0.1:10808 IMAGE_TAG=live-recorder-webapi:arm64
# OUTPUT=live-recorder-webapi-arm64.tar.gz WSL_DISTRO=Ubuntu
# PROXY=http://127.0.0.1:10808
# OUTPUT=live-recorder-arm64.tar.gz
# WSL_DISTRO=Ubuntu
set -euo pipefail
# --- Resolve repo root (script lives in <root>/scripts) ---
@@ -30,30 +38,26 @@ cd "$ROOT_DIR"
# --- Config ---
PROXY="${PROXY:-http://127.0.0.1:10808}"
PROXY_HOST="${PROXY_HOST:-http://host.docker.internal:${PROXY##*:}}" # container-visible proxy
IMAGE_TAG="${IMAGE_TAG:-live-recorder-webapi:arm64}"
OUTPUT="${OUTPUT:-live-recorder-webapi-arm64.tar.gz}"
DOCKERFILE="${DOCKERFILE:-src/LiveRecorder.WebApi/Dockerfile}"
API_IMAGE_TAG="${API_IMAGE_TAG:-live-recorder-webapi:arm64}"
FRONTEND_IMAGE_TAG="${FRONTEND_IMAGE_TAG:-live-recorder-frontend:arm64}"
OUTPUT="${OUTPUT:-live-recorder-arm64.tar.gz}"
WSL_DISTRO="${WSL_DISTRO:-Ubuntu}"
SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim"
RUNTIME_IMAGE="mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim"
DOTNET_SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim"
DOTNET_RUNTIME_IMAGE="mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim"
NGINX_IMAGE="${NGINX_IMAGE:-nginx:1.27-alpine}"
NO_PROXY_HOSTS="mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,localhost,127.0.0.1"
log() { printf '\n\033[1;36m[%s] %s\033[0m\n' "$(date +%H:%M:%S)" "$*"; }
die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
# --- 1. Ensure ARM64 emulation works -----------------------------------------
# binfmt_misc lives in the (shared) WSL2 kernel and is lost on `wsl --shutdown`/reboot,
# so this is idempotent and re-runs the registration whenever emulation is missing.
ensure_arm64_emulation() {
if docker run --rm --platform linux/arm64 "$RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then
if docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then
log "ARM64 emulation already works — skipping binfmt registration."
return 0
fi
log "Registering qemu-aarch64 emulation via the '$WSL_DISTRO' WSL distro..."
# Install the statically-linked emulator inside the distro, then re-register it pointing
# directly at the static binary with the F (fix-binary) flag — the kernel opens the
# interpreter at registration time so the held fd works inside Docker build containers.
wsl.exe -d "$WSL_DISTRO" -u root -- bash -lc '
set -e
if [ ! -x /usr/bin/qemu-aarch64-static ]; then
@@ -67,7 +71,7 @@ ensure_arm64_emulation() {
> /proc/sys/fs/binfmt_misc/register
' || die "binfmt registration failed (need a Debian/Ubuntu WSL distro named '$WSL_DISTRO' with apt)."
docker run --rm --platform linux/arm64 "$RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64 \
docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64 \
|| die "ARM64 emulation still not working after registration."
log "ARM64 emulation registered and verified."
}
@@ -85,33 +89,53 @@ pull_with_retry() {
docker image inspect "$img" >/dev/null 2>&1 || die "Could not pull $img (network)."
}
# --- 3. Build ----------------------------------------------------------------
build_image() {
log "Building $IMAGE_TAG for linux/arm64 (restore via proxy, apt via mirror)..."
# --- 3. Build API image ------------------------------------------------------
build_api_image() {
log "Building $API_IMAGE_TAG for linux/arm64 (restore via proxy, apt via mirror)..."
docker buildx build \
--platform linux/arm64 \
-f "$DOCKERFILE" \
-t "$IMAGE_TAG" \
-f src/LiveRecorder.WebApi/Dockerfile \
-t "$API_IMAGE_TAG" \
--build-arg "HTTP_PROXY=$PROXY_HOST" \
--build-arg "HTTPS_PROXY=$PROXY_HOST" \
--build-arg "NO_PROXY=$NO_PROXY_HOSTS" \
--build-arg "DOTNET_SDK_IMAGE=$DOTNET_SDK_IMAGE" \
--build-arg "DOTNET_RUNTIME_IMAGE=$DOTNET_RUNTIME_IMAGE" \
--pull=false \
--load \
.
}
# --- 4. Export + compress ----------------------------------------------------
export_image() {
log "Exporting $IMAGE_TAG -> $OUTPUT ..."
# --- 4. Build Frontend (nginx) image -----------------------------------------
# Frontend dist is pre-built locally (npm run build inside frontend/) and copied
# directly — avoids running Node.js under QEMU ARM64 emulation which is prohibitively
# slow. Build context is frontend/ (not repo root) to bypass .dockerignore rules.
build_frontend_image() {
log "Building $FRONTEND_IMAGE_TAG for linux/arm64 (pre-built dist -> nginx)..."
docker buildx build \
--platform linux/arm64 \
-f frontend/Dockerfile.arm64 \
-t "$FRONTEND_IMAGE_TAG" \
--pull=false \
--load \
frontend
}
# --- 5. Export both images as a single archive -------------------------------
export_images() {
log "Exporting $API_IMAGE_TAG + $FRONTEND_IMAGE_TAG -> $OUTPUT ..."
local tar="${OUTPUT%.gz}"
docker save "$IMAGE_TAG" -o "$tar"
docker save "$API_IMAGE_TAG" "$FRONTEND_IMAGE_TAG" -o "$tar"
gzip -f "$tar"
log "Done: $(ls -lh "$OUTPUT" | awk '{print $5, $9}')"
log "Load on an arm64 host with: docker load < $OUTPUT"
log "Then start with: docker compose up -d"
}
ensure_arm64_emulation
pull_with_retry "$SDK_IMAGE"
pull_with_retry "$RUNTIME_IMAGE"
build_image
export_image
pull_with_retry "$DOTNET_SDK_IMAGE"
pull_with_retry "$DOTNET_RUNTIME_IMAGE"
pull_with_retry "$NGINX_IMAGE"
build_api_image
build_frontend_image
export_images
+173
View File
@@ -0,0 +1,173 @@
#!/bin/sh
# =========================
# 基础配置
# =========================
OPENLIST_BASE_URL="http://192.168.6.145:5244"
OPENLIST_USERNAME="${OPENLIST_USERNAME:-admin}"
OPENLIST_PASSWORD="${OPENLIST_PASSWORD:-768788}"
segment_path="$LIVE_RECORDER_SEGMENT_FILE_PATH"
danmaku_path="$LIVE_RECORDER_DANMAKU_FILE_PATH"
platform="$LIVE_RECORDER_PLATFORM"
log_file="$LIVE_RECORDER_SCRIPT_LOG_PATH"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$log_file"
}
# =========================
# 参数检查
# =========================
if [ -z "$segment_path" ]; then
log "错误:LIVE_RECORDER_SEGMENT_FILE_PATH 为空"
exit 1
fi
if [ -z "$danmaku_path" ]; then
log "错误:LIVE_RECORDER_DANMAKU_FILE_PATH 为空"
exit 1
fi
if [ -z "$platform" ]; then
log "错误:LIVE_RECORDER_PLATFORM 为空"
exit 1
fi
# =========================
# 简单 JSON 字符串转义
# =========================
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
# 从响应里抽取业务 code
extract_code() {
printf '%s' "$1" | sed -n 's/.*"code"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p'
}
# =========================
# 1. 获取主播名和日期
# =========================
anchor_name=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-2)}')
record_date=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-1)}')
video_filename=$(basename "$segment_path")
danmaku_filename=$(basename "$danmaku_path")
log "主播名: $anchor_name"
log "日期: $record_date"
log "视频文件名: $video_filename"
log "弹幕文件名: $danmaku_filename"
remote_dir="/yidongpan/records/$anchor_name/$record_date"
src_dir="/local/home/nanxun/live_recorder/records/$platform/$anchor_name/$record_date"
log "OpenList 源目录: $src_dir"
log "OpenList 目标目录: $remote_dir"
# 转义后用于 JSON
username_json=$(json_escape "$OPENLIST_USERNAME")
password_json=$(json_escape "$OPENLIST_PASSWORD")
remote_dir_json=$(json_escape "$remote_dir")
src_dir_json=$(json_escape "$src_dir")
video_filename_json=$(json_escape "$video_filename")
danmaku_filename_json=$(json_escape "$danmaku_filename")
# =========================
# 2. 登录获取 token
# =========================
login_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/auth/login" \
--header 'Content-Type: application/json' \
--data-raw "{\"username\":\"$username_json\",\"password\":\"$password_json\"}")
login_code=$(extract_code "$login_resp")
token=$(printf '%s' "$login_resp" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ "$login_code" != "200" ] || [ -z "$token" ]; then
log "错误:登录失败,响应: $login_resp"
exit 1
fi
log "OpenList 登录成功"
# =========================
# 3. 递归创建目标目录
# OpenList 的 /api/fs/mkdir 在部分网盘驱动(如移动云盘)上
# 不会自动创建父目录,父目录缺失时会报
# "failed to get parent dir [...]: object not found"。
# 因此这里从根开始逐级 list + mkdir,确保每一层父目录都已存在。
# =========================
# 先判断完整目标目录是否已存在,存在则直接跳过创建
list_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \
--header "Authorization: $token" \
--header 'Content-Type: application/json' \
--data-raw "{\"path\":\"$remote_dir_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}")
list_code=$(extract_code "$list_resp")
if [ "$list_code" = "200" ]; then
log "目标目录已存在: $remote_dir"
else
log "目标目录不存在,逐级创建: $remote_dir"
accum=""
old_ifs="$IFS"
IFS='/'
for seg in $remote_dir; do
[ -z "$seg" ] && continue
accum="$accum/$seg"
accum_json=$(json_escape "$accum")
# 该层是否已存在?存在则跳过,避免无谓 mkdir 报错
seg_list=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \
--header "Authorization: $token" \
--header 'Content-Type: application/json' \
--data-raw "{\"path\":\"$accum_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}")
seg_code=$(extract_code "$seg_list")
if [ "$seg_code" = "200" ]; then
continue
fi
mk_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/mkdir" \
--header "Authorization: $token" \
--header 'Content-Type: application/json' \
--data-raw "{\"path\":\"$accum_json\"}")
mk_code=$(extract_code "$mk_resp")
if [ "$mk_code" != "200" ]; then
IFS="$old_ifs"
log "错误:创建目录失败 ($accum),响应: $mk_resp"
exit 1
fi
log "已创建: $accum"
done
IFS="$old_ifs"
log "目标目录创建成功: $remote_dir"
fi
# =========================
# 4. 移动文件到目标目录
# =========================
move_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/move" \
--header 'Content-Type: application/json' \
--header "Authorization: $token" \
--data-raw "{\"src_dir\":\"$src_dir_json\",\"dst_dir\":\"$remote_dir_json\",\"names\":[\"$danmaku_filename_json\",\"$video_filename_json\"]}")
move_code=$(extract_code "$move_resp")
if [ "$move_code" = "200" ]; then
log "文件移动成功: $danmaku_filename, $video_filename -> $remote_dir"
else
log "错误:文件移动失败,响应: $move_resp"
exit 1
fi
exit 0