feat: add native fnOS package
This commit is contained in:
@@ -252,6 +252,19 @@ docker compose up -d
|
||||
- 数据目录 `./data` 与录制目录 `./records` 映射到宿主机
|
||||
- 支持多架构构建(`linux/amd64`, `linux/arm64`)
|
||||
|
||||
### fnOS 原生 FPK
|
||||
|
||||
```bash
|
||||
./scripts/build-fnos-package.sh
|
||||
./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.0.0-x86_64.fpk
|
||||
```
|
||||
|
||||
- x86_64 原生自包含包,不依赖 Docker、系统 .NET、PostgreSQL、Node.js 或 FFmpeg
|
||||
- 安装向导会要求设置 `admin` 管理员密码
|
||||
- Web 管理界面默认使用端口 `18080`
|
||||
- 数据库与日志保存在 fnOS 应用持久化目录
|
||||
- 录制文件保存在 fnOS 共享目录 `liverecorder/records`
|
||||
|
||||
## 验证
|
||||
|
||||
- `dotnet build LiveRecorder.sln --no-restore -m:1`
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
PASSWORD="${wizard_admin_password:-}"
|
||||
PASSWORD_CONFIRM="${wizard_admin_password_confirm:-}"
|
||||
unset wizard_admin_password wizard_admin_password_confirm
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
|
||||
printf '%s\n' "$1" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ "$PASSWORD" = "$PASSWORD_CONFIRM" ] || fail "管理员密码两次输入不一致。"
|
||||
[ "${#PASSWORD}" -ge 10 ] || fail "管理员密码至少需要 10 个字符。"
|
||||
[ "${#PASSWORD}" -le 256 ] || fail "管理员密码不能超过 256 个字符。"
|
||||
case "$PASSWORD" in
|
||||
*$'\n'*|*$'\r'*) fail "管理员密码不能包含换行符。" ;;
|
||||
esac
|
||||
|
||||
mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log"
|
||||
chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true
|
||||
umask 077
|
||||
printf '%s\n' "$PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed"
|
||||
unset PASSWORD PASSWORD_CONFIRM
|
||||
exit 0
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
|
||||
PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT}"
|
||||
DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}"
|
||||
VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}"
|
||||
RECORD_ROOT="${LIVE_RECORDER_RECORD_ROOT:-$VOLUME_ROOT/@appshare/liverecorder/records}"
|
||||
RUNTIME_ROOT="$APP_ROOT/runtime"
|
||||
SERVER="$APP_ROOT/server/LiveRecorder.WebApi"
|
||||
PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin"
|
||||
PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15"
|
||||
PG_DATA="$DATA_ROOT/postgres"
|
||||
RUN_ROOT="$DATA_ROOT/run"
|
||||
LOG_ROOT="$DATA_ROOT/log"
|
||||
APP_PID_FILE="$RUN_ROOT/liverecorder.pid"
|
||||
APP_LOG="$LOG_ROOT/liverecorder.log"
|
||||
PG_LOG="$LOG_ROOT/postgresql.log"
|
||||
ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed"
|
||||
PG_PORT="${LIVE_RECORDER_POSTGRES_PORT:-54329}"
|
||||
SERVICE_PORT="${TRIM_SERVICE_PORT:-18080}"
|
||||
RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib/x86_64-linux-gnu:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/pulseaudio:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/blas:$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu/lapack:$RUNTIME_ROOT/usr/lib/postgresql/15/lib"
|
||||
|
||||
log_message() {
|
||||
mkdir -p "$LOG_ROOT"
|
||||
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG"
|
||||
}
|
||||
|
||||
app_pid() {
|
||||
if [ -f "$APP_PID_FILE" ]; then
|
||||
pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]')
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
printf '%s' "$pid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
run_pg() {
|
||||
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" "$@"
|
||||
}
|
||||
|
||||
run_native() {
|
||||
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_ROOT/usr/bin:/usr/bin:/bin" "$@"
|
||||
}
|
||||
|
||||
postgres_running() {
|
||||
[ -x "$PG_BIN/pg_ctl" ] || return 1
|
||||
[ -f "$PG_DATA/PG_VERSION" ] || return 1
|
||||
run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" status >/dev/null 2>&1
|
||||
}
|
||||
|
||||
initialize_postgres() {
|
||||
if [ -f "$PG_DATA/PG_VERSION" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$PG_DATA" "$RUN_ROOT"
|
||||
chmod 0700 "$PG_DATA" "$RUN_ROOT"
|
||||
if ! run_pg "$PG_BIN/initdb" \
|
||||
-D "$PG_DATA" \
|
||||
-L "$PG_SHARE" \
|
||||
--username=liverecorder \
|
||||
--auth-local=trust \
|
||||
--auth-host=reject \
|
||||
--encoding=UTF8 \
|
||||
--no-locale >>"$PG_LOG" 2>&1; then
|
||||
log_message "PostgreSQL 初始化失败。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf "listen_addresses = ''\n"
|
||||
printf "port = %s\n" "$PG_PORT"
|
||||
printf "unix_socket_directories = '%s'\n" "$RUN_ROOT"
|
||||
printf "max_connections = 40\n"
|
||||
printf "shared_buffers = '64MB'\n"
|
||||
printf "timezone = 'UTC'\n"
|
||||
printf "log_timezone = 'UTC'\n"
|
||||
} >>"$PG_DATA/postgresql.conf"
|
||||
return 0
|
||||
}
|
||||
|
||||
start_postgres() {
|
||||
if postgres_running; then
|
||||
return 0
|
||||
fi
|
||||
initialize_postgres || return 1
|
||||
if ! run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" -w start; then
|
||||
log_message "PostgreSQL 启动失败。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
database_exists=$(run_pg "$PG_BIN/psql" \
|
||||
-h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d postgres -Atqc \
|
||||
"SELECT 1 FROM pg_database WHERE datname = 'live_recorder'" 2>/dev/null || true)
|
||||
if [ "$database_exists" != "1" ]; then
|
||||
if ! run_pg "$PG_BIN/createdb" \
|
||||
-h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder live_recorder >>"$PG_LOG" 2>&1; then
|
||||
log_message "Live Recorder 数据库创建失败。"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_postgres() {
|
||||
if postgres_running; then
|
||||
run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -w stop >>"$PG_LOG" 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
launch_app_process() {
|
||||
(
|
||||
export ASPNETCORE_ENVIRONMENT=Production
|
||||
export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT"
|
||||
export ConnectionStrings__DefaultConnection="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30"
|
||||
export LIVE_RECORDER_DEFAULT_OUTPUT_ROOT="$RECORD_ROOT"
|
||||
if [ -f "$ADMIN_PASSWORD_FILE" ]; then
|
||||
LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD=$(sed -n '1p' "$ADMIN_PASSWORD_FILE")
|
||||
export LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD
|
||||
fi
|
||||
export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
|
||||
export DOTNET_BUNDLE_EXTRACT_BASE_DIR="$DATA_ROOT/dotnet-bundle"
|
||||
export XDG_CACHE_HOME="$DATA_ROOT/cache"
|
||||
export TMPDIR="$DATA_ROOT/tmp"
|
||||
export LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH"
|
||||
export PATH="$RUNTIME_ROOT/usr/bin:/usr/bin:/bin"
|
||||
mkdir -p "$XDG_CACHE_HOME" "$TMPDIR"
|
||||
cd "$APP_ROOT/server" || exit 1
|
||||
exec "$SERVER"
|
||||
) >>"$APP_LOG" 2>&1 &
|
||||
APP_PROCESS_PID=$!
|
||||
printf '%s\n' "$APP_PROCESS_PID" >"$APP_PID_FILE"
|
||||
}
|
||||
|
||||
start_app() {
|
||||
mkdir -p "$DATA_ROOT" "$RUN_ROOT" "$LOG_ROOT" "$RECORD_ROOT" "$DATA_ROOT/dotnet-bundle"
|
||||
chmod 0700 "$DATA_ROOT" "$RUN_ROOT" "$DATA_ROOT/dotnet-bundle" 2>/dev/null || true
|
||||
|
||||
if [ ! -x "$SERVER" ]; then
|
||||
log_message "应用程序不存在或不可执行:$SERVER"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "$PG_BIN/postgres" ] || [ ! -x "$RUNTIME_ROOT/usr/bin/ffmpeg" ] || [ ! -x "$RUNTIME_ROOT/usr/bin/node" ]; then
|
||||
log_message "FPK 原生运行环境不完整。"
|
||||
return 1
|
||||
fi
|
||||
if pid=$(app_pid); then
|
||||
log_message "应用已运行,PID $pid。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -f "$APP_PID_FILE"
|
||||
start_postgres || return 1
|
||||
launch_attempt=1
|
||||
launch_app_process
|
||||
pid=$APP_PROCESS_PID
|
||||
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 90 ]; do
|
||||
if run_native "$RUNTIME_ROOT/usr/bin/curl" -fsS "http://127.0.0.1:$SERVICE_PORT/health/ready" >/dev/null 2>&1; then
|
||||
rm -f "$ADMIN_PASSWORD_FILE"
|
||||
log_message "应用启动成功,PID $pid,端口 $SERVICE_PORT。"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
if [ "$launch_attempt" -ge 3 ]; then
|
||||
break
|
||||
fi
|
||||
log_message "应用启动进程提前退出,3 秒后重试($launch_attempt/3)。"
|
||||
sleep 3
|
||||
launch_attempt=$((launch_attempt + 1))
|
||||
launch_app_process
|
||||
pid=$APP_PROCESS_PID
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_message "应用未能在 90 秒内就绪。"
|
||||
stop_app
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
if pid=$(app_pid); then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
attempt=0
|
||||
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
rm -f "$APP_PID_FILE"
|
||||
stop_postgres
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
start) start_app ;;
|
||||
stop) stop_app ;;
|
||||
restart) stop_app; start_app ;;
|
||||
status)
|
||||
if app_pid >/dev/null && postgres_running; then
|
||||
exit 0
|
||||
fi
|
||||
exit 3
|
||||
;;
|
||||
*) printf 'usage: %s {start|stop|restart|status}\n' "$0" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Persistent PostgreSQL data and recordings are preserved by default.
|
||||
exit 0
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
"$(dirname -- "$0")/main" stop || true
|
||||
exit 0
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# PostgreSQL data, logs and application settings live in TRIM_PKGVAR and are
|
||||
# intentionally not touched while fnOS replaces the immutable application.
|
||||
exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"defaults": {
|
||||
"run-as": "package"
|
||||
},
|
||||
"join-groups": ["video"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data-share": {
|
||||
"shares": [
|
||||
{
|
||||
"name": "liverecorder",
|
||||
"permission": {
|
||||
"rw": ["liverecorder"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "liverecorder/records",
|
||||
"permission": {
|
||||
"rw": ["liverecorder"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
appname=liverecorder
|
||||
version=1.0.0
|
||||
display_name=Live Recorder
|
||||
desc=原生自包含直播录制系统,内置 PostgreSQL、FFmpeg、Node.js 和 Web 管理界面,支持分片录制、弹幕采集与 OpenList 自动上传
|
||||
platform=x86
|
||||
source=thirdparty
|
||||
maintainer=Live Recorder Contributors
|
||||
os_min_version=1.2.0
|
||||
desktop_uidir=ui
|
||||
desktop_applaunchname=liverecorder.Application
|
||||
checksum=@CHECKSUM@
|
||||
checkport=true
|
||||
ctl_stop=true
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
".url": {
|
||||
"liverecorder.Application": {
|
||||
"title": "Live Recorder",
|
||||
"icon": "images/icon_{0}.png",
|
||||
"type": "url",
|
||||
"protocol": "",
|
||||
"port": "18080",
|
||||
"url": "/",
|
||||
"allUsers": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "设置 Live Recorder 管理员密码",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "此密码用于登录 Live Recorder,不是 fnOS 系统密码。用户名固定为 admin。"
|
||||
},
|
||||
{
|
||||
"type": "password",
|
||||
"field": "wizard_admin_password",
|
||||
"label": "管理员密码",
|
||||
"rules": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "请输入管理员密码"
|
||||
},
|
||||
{
|
||||
"min": 10,
|
||||
"message": "管理员密码至少需要 10 个字符"
|
||||
},
|
||||
{
|
||||
"max": 256,
|
||||
"message": "管理员密码不能超过 256 个字符"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "password",
|
||||
"field": "wizard_admin_password_confirm",
|
||||
"label": "再次输入密码",
|
||||
"rules": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "请再次输入管理员密码"
|
||||
},
|
||||
{
|
||||
"min": 10,
|
||||
"message": "管理员密码至少需要 10 个字符"
|
||||
},
|
||||
{
|
||||
"max": 256,
|
||||
"message": "管理员密码不能超过 256 个字符"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "升级 Live Recorder",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "升级会保留 PostgreSQL 数据库、系统设置、上传任务和录制文件。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
VERSION=1.0.0
|
||||
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}"
|
||||
WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd)
|
||||
DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}"
|
||||
NUGET_FEED="${LIVERECORDER_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}"
|
||||
NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}"
|
||||
DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}"
|
||||
BUILD_TMP_ROOT="${LIVERECORDER_BUILD_TMPDIR:-$WORKSPACE_CACHE/.fnos-build-tmp}"
|
||||
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT_DIR" show -s --format=%ct HEAD)}"
|
||||
|
||||
mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")"
|
||||
WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/liverecorder-fnos-build.XXXXXX")
|
||||
trap 'rm -rf -- "$WORK_DIR"' EXIT
|
||||
|
||||
for command_name in npm apt-get dpkg-deb tar md5sum sha256sum node; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || {
|
||||
printf 'required build command is missing: %s\n' "$command_name" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
test -x "$DOTNET_BIN" || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; }
|
||||
test -d "$NUGET_FEED" || { printf 'missing offline NuGet feed: %s\n' "$NUGET_FEED" >&2; exit 1; }
|
||||
|
||||
printf 'Building frontend...\n'
|
||||
npm run build --prefix "$ROOT_DIR/frontend"
|
||||
|
||||
printf 'Publishing self-contained .NET application...\n'
|
||||
export NUGET_PACKAGES DOTNET_CLI_HOME
|
||||
"$DOTNET_BIN" restore "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \
|
||||
-r linux-x64 \
|
||||
--source "$NUGET_FEED" \
|
||||
--source "https://api.nuget.org/v3/index.json" \
|
||||
--disable-parallel
|
||||
"$DOTNET_BIN" publish "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
--no-restore \
|
||||
-p:DebugType=None \
|
||||
-p:DebugSymbols=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-o "$WORK_DIR/payload/server" \
|
||||
/maxcpucount:1
|
||||
rm -f "$WORK_DIR/payload/server/"*.pdb
|
||||
mkdir -p "$WORK_DIR/payload/server/wwwroot"
|
||||
cp -a "$ROOT_DIR/frontend/dist/." "$WORK_DIR/payload/server/wwwroot/"
|
||||
|
||||
printf 'Downloading pinned Debian Bookworm native runtime packages...\n'
|
||||
APT_ROOT="$WORK_DIR/apt"
|
||||
mkdir -p \
|
||||
"$APT_ROOT/etc/apt" \
|
||||
"$APT_ROOT/var/lib/apt/lists/partial" \
|
||||
"$APT_ROOT/var/lib/dpkg" \
|
||||
"$APT_ROOT/var/cache/apt/archives/partial"
|
||||
cp "$ROOT_DIR/scripts/fnos-bookworm.sources.list" "$APT_ROOT/etc/apt/sources.list"
|
||||
touch "$APT_ROOT/var/lib/dpkg/status"
|
||||
|
||||
APT_OPTIONS=(
|
||||
-o "Dir::Etc::sourcelist=$APT_ROOT/etc/apt/sources.list"
|
||||
-o "Dir::Etc::sourceparts=-"
|
||||
-o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status"
|
||||
-o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists"
|
||||
-o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives"
|
||||
-o "Debug::NoLocking=1"
|
||||
-o "APT::Architecture=amd64"
|
||||
-o "Acquire::Languages=none"
|
||||
)
|
||||
apt-get "${APT_OPTIONS[@]}" update
|
||||
apt-get "${APT_OPTIONS[@]}" \
|
||||
--download-only \
|
||||
--no-install-recommends \
|
||||
--yes \
|
||||
install \
|
||||
postgresql-15 \
|
||||
postgresql-client-15 \
|
||||
nodejs \
|
||||
ffmpeg \
|
||||
curl \
|
||||
ca-certificates
|
||||
|
||||
RUNTIME_ROOT="$WORK_DIR/payload/runtime"
|
||||
mkdir -p "$RUNTIME_ROOT"
|
||||
shopt -s nullglob
|
||||
packages=("$APT_ROOT"/var/cache/apt/archives/*.deb)
|
||||
test "${#packages[@]}" -gt 0 || { printf 'APT did not download runtime packages\n' >&2; exit 1; }
|
||||
for package_file in "${packages[@]}"; do
|
||||
case "$(basename -- "$package_file")" in
|
||||
libc6_*|libc-bin_*)
|
||||
# Native programs must use the fnOS glibc/loader as one matched
|
||||
# pair. Bundling Debian's libc while an executable still starts
|
||||
# through the host loader can crash before main() on newer fnOS
|
||||
# releases. All other runtime libraries remain private to the app.
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
dpkg-deb -x "$package_file" "$RUNTIME_ROOT"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
for forbidden_glibc_file in \
|
||||
"$RUNTIME_ROOT/lib/x86_64-linux-gnu/libc.so.6" \
|
||||
"$RUNTIME_ROOT/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"; do
|
||||
test ! -e "$forbidden_glibc_file" || {
|
||||
printf 'host glibc must not be shadowed: %s\n' "$forbidden_glibc_file" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
rm -rf \
|
||||
"$RUNTIME_ROOT/usr/share/doc" \
|
||||
"$RUNTIME_ROOT/usr/share/man" \
|
||||
"$RUNTIME_ROOT/usr/share/lintian" \
|
||||
"$RUNTIME_ROOT/usr/share/locale"
|
||||
if [ ! -e "$RUNTIME_ROOT/usr/bin/node" ] && [ -x "$RUNTIME_ROOT/usr/bin/nodejs" ]; then
|
||||
ln -s nodejs "$RUNTIME_ROOT/usr/bin/node"
|
||||
fi
|
||||
|
||||
for required_file in \
|
||||
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \
|
||||
"$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \
|
||||
"$RUNTIME_ROOT/usr/bin/node" \
|
||||
"$RUNTIME_ROOT/usr/bin/ffmpeg" \
|
||||
"$RUNTIME_ROOT/usr/bin/curl"; do
|
||||
test -x "$required_file" || { printf 'native runtime file is missing: %s\n' "$required_file" >&2; exit 1; }
|
||||
done
|
||||
|
||||
mkdir -p "$WORK_DIR/payload/ui" "$WORK_DIR/package"
|
||||
cp "$ROOT_DIR/fnos/ui/config" "$WORK_DIR/payload/ui/config"
|
||||
node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$WORK_DIR/package" "$WORK_DIR/payload/ui/images"
|
||||
|
||||
printf 'Packing fnOS payload...\n'
|
||||
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \
|
||||
-czf "$WORK_DIR/package/app.tgz" \
|
||||
-C "$WORK_DIR/payload" \
|
||||
server runtime ui
|
||||
cp -a "$ROOT_DIR/fnos/cmd" "$ROOT_DIR/fnos/config" "$ROOT_DIR/fnos/wizard" "$WORK_DIR/package/"
|
||||
chmod 0755 "$WORK_DIR/package/cmd/"*
|
||||
checksum=$(md5sum "$WORK_DIR/package/app.tgz" | cut -d' ' -f1)
|
||||
sed "s/@CHECKSUM@/$checksum/" "$ROOT_DIR/fnos/manifest" >"$WORK_DIR/package/manifest"
|
||||
|
||||
tar --sort=name --mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \
|
||||
-czf "$OUTPUT" \
|
||||
-C "$WORK_DIR/package" \
|
||||
app.tgz cmd config wizard ICON.PNG ICON_256.PNG manifest
|
||||
(
|
||||
cd "$(dirname -- "$OUTPUT")"
|
||||
sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256"
|
||||
)
|
||||
|
||||
"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT"
|
||||
printf 'Built %s\n' "$OUTPUT"
|
||||
@@ -0,0 +1,3 @@
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main
|
||||
@@ -0,0 +1,131 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const [packageRoot, uiImageRoot] = process.argv.slice(2);
|
||||
if (!packageRoot || !uiImageRoot) {
|
||||
throw new Error("usage: node generate-fnos-icons.mjs package-root ui-image-root");
|
||||
}
|
||||
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
crcTable[index] = value >>> 0;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
}
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const checksum = Buffer.alloc(4);
|
||||
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])));
|
||||
return Buffer.concat([length, typeBuffer, data, checksum]);
|
||||
}
|
||||
|
||||
function roundedRectangleDistance(x, y, left, top, right, bottom, radius) {
|
||||
const centerX = (left + right) / 2;
|
||||
const centerY = (top + bottom) / 2;
|
||||
const halfWidth = (right - left) / 2 - radius;
|
||||
const halfHeight = (bottom - top) / 2 - radius;
|
||||
const dx = Math.max(Math.abs(x - centerX) - halfWidth, 0);
|
||||
const dy = Math.max(Math.abs(y - centerY) - halfHeight, 0);
|
||||
return Math.hypot(dx, dy) - radius;
|
||||
}
|
||||
|
||||
function render(size) {
|
||||
const pixels = Buffer.alloc(size * size * 4);
|
||||
const samples = 3;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const rgba = [0, 0, 0, 0];
|
||||
for (let sy = 0; sy < samples; sy++) {
|
||||
for (let sx = 0; sx < samples; sx++) {
|
||||
const px = x + (sx + 0.5) / samples;
|
||||
const py = y + (sy + 0.5) / samples;
|
||||
const scale = size / 256;
|
||||
let color = [0, 0, 0, 0];
|
||||
const background = roundedRectangleDistance(px, py, 12 * scale, 12 * scale, 244 * scale, 244 * scale, 50 * scale);
|
||||
if (background <= 0) {
|
||||
const mix = Math.min(1, Math.max(0, (px + py) / (512 * scale)));
|
||||
color = [Math.round(27 + 33 * mix), Math.round(94 + 66 * mix), Math.round(180 + 49 * mix), 255];
|
||||
}
|
||||
|
||||
const body = roundedRectangleDistance(px, py, 51 * scale, 75 * scale, 190 * scale, 185 * scale, 22 * scale);
|
||||
if (body <= 0) {
|
||||
color = [245, 249, 255, 255];
|
||||
}
|
||||
|
||||
const lensDistance = Math.hypot(px - 120 * scale, py - 130 * scale);
|
||||
if (lensDistance <= 36 * scale) {
|
||||
color = lensDistance <= 23 * scale ? [42, 109, 196, 255] : [128, 185, 239, 255];
|
||||
}
|
||||
|
||||
const viewfinder = roundedRectangleDistance(px, py, 73 * scale, 56 * scale, 122 * scale, 86 * scale, 9 * scale);
|
||||
if (viewfinder <= 0) {
|
||||
color = [232, 241, 253, 255];
|
||||
}
|
||||
|
||||
if (px >= 190 * scale && px <= 222 * scale && py >= 101 * scale && py <= 159 * scale) {
|
||||
const edge = Math.abs(py - 130 * scale) / (29 * scale);
|
||||
const leftEdge = 190 * scale + edge * 16 * scale;
|
||||
if (px >= leftEdge) {
|
||||
color = [237, 244, 253, 255];
|
||||
}
|
||||
}
|
||||
|
||||
const statusDistance = Math.hypot(px - 165 * scale, py - 98 * scale);
|
||||
if (statusDistance <= 9 * scale) {
|
||||
color = [244, 85, 93, 255];
|
||||
}
|
||||
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
rgba[channel] += color[channel];
|
||||
}
|
||||
}
|
||||
}
|
||||
const offset = (y * size + x) * 4;
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
pixels[offset + channel] = Math.round(rgba[channel] / (samples * samples));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanlines = Buffer.alloc((size * 4 + 1) * size);
|
||||
for (let row = 0; row < size; row++) {
|
||||
const target = row * (size * 4 + 1);
|
||||
scanlines[target] = 0;
|
||||
pixels.copy(scanlines, target + 1, row * size * 4, (row + 1) * size * 4);
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", header),
|
||||
chunk("IDAT", deflateSync(scanlines, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
mkdirSync(uiImageRoot, { recursive: true });
|
||||
const icon64 = render(64);
|
||||
const icon256 = render(256);
|
||||
writeFileSync(join(packageRoot, "ICON.PNG"), icon64);
|
||||
writeFileSync(join(packageRoot, "ICON_256.PNG"), icon256);
|
||||
writeFileSync(join(uiImageRoot, "icon_64.png"), icon64);
|
||||
writeFileSync(join(uiImageRoot, "icon_256.png"), icon256);
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE=${1:?usage: smoke-fnos-package.sh package.fpk [temporary-directory]}
|
||||
SMOKE_TMP_ROOT="${2:-${LIVERECORDER_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
|
||||
mkdir -p "$SMOKE_TMP_ROOT"
|
||||
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
|
||||
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-fnos-smoke.XXXXXX")
|
||||
PORT=${LIVERECORDER_SMOKE_PORT:-19180}
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if [ -x "$WORK_DIR/cmd/main" ]; then
|
||||
TRIM_APPDEST="$WORK_DIR" \
|
||||
TRIM_PKGVAR="$WORK_DIR/var" \
|
||||
TRIM_APPDEST_VOL="$WORK_DIR/volume" \
|
||||
TRIM_SERVICE_PORT="$PORT" \
|
||||
"$WORK_DIR/cmd/main" stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$status" -ne 0 ]; then
|
||||
printf '%s\n' 'fnOS smoke test failed; application logs follow:' >&2
|
||||
for log_file in "$WORK_DIR/var/log/postgresql.log" "$WORK_DIR/var/log/liverecorder.log"; do
|
||||
if [ -f "$log_file" ]; then
|
||||
printf '%s\n' "--- $log_file ---" >&2
|
||||
tail -n 120 "$log_file" >&2 || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
rm -rf -- "$WORK_DIR"
|
||||
return "$status"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
tar -xzf "$PACKAGE" -C "$WORK_DIR"
|
||||
tar -xzf "$WORK_DIR/app.tgz" -C "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR/volume"
|
||||
|
||||
export TRIM_APPDEST="$WORK_DIR"
|
||||
export TRIM_PKGVAR="$WORK_DIR/var"
|
||||
export TRIM_APPDEST_VOL="$WORK_DIR/volume"
|
||||
export TRIM_SERVICE_PORT="$PORT"
|
||||
|
||||
SMOKE_PASSWORD='LiveRecorder-Smoke-2026!'
|
||||
wizard_admin_password="$SMOKE_PASSWORD" \
|
||||
wizard_admin_password_confirm="$SMOKE_PASSWORD" \
|
||||
"$WORK_DIR/cmd/install_callback"
|
||||
"$WORK_DIR/cmd/main" start
|
||||
"$WORK_DIR/cmd/main" status
|
||||
|
||||
BASE_URL="http://127.0.0.1:$PORT"
|
||||
curl -fsS "$BASE_URL/" >"$WORK_DIR/index.html"
|
||||
grep -q '<div id="app"></div>' "$WORK_DIR/index.html"
|
||||
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
|
||||
|
||||
login_response=$(curl -fsS \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "{\"username\":\"admin\",\"password\":\"$SMOKE_PASSWORD\"}" \
|
||||
"$BASE_URL/api/auth/login")
|
||||
token=$(printf '%s' "$login_response" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
||||
test -n "$token"
|
||||
|
||||
settings_response=$(curl -fsS -H "Authorization: Bearer $token" "$BASE_URL/api/settings")
|
||||
expected_record_root="$WORK_DIR/volume/@appshare/liverecorder/records"
|
||||
printf '%s' "$settings_response" | grep -Fq "\"outputRoot\":\"$expected_record_root\""
|
||||
|
||||
runtime_libs="$WORK_DIR/runtime/lib/x86_64-linux-gnu:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/pulseaudio:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/blas:$WORK_DIR/runtime/usr/lib/x86_64-linux-gnu/lapack:$WORK_DIR/runtime/usr/lib/postgresql/15/lib"
|
||||
LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/node" --version >/dev/null
|
||||
LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/ffmpeg" -version >/dev/null 2>&1
|
||||
LD_LIBRARY_PATH="$runtime_libs" "$WORK_DIR/runtime/usr/bin/curl" -fsS "$BASE_URL/health/ready" >/dev/null
|
||||
|
||||
"$WORK_DIR/cmd/main" stop
|
||||
"$WORK_DIR/cmd/main" start
|
||||
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
|
||||
|
||||
printf 'fnOS native smoke test passed: frontend, API, PostgreSQL, Node.js and FFmpeg are ready\n'
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE=${1:?usage: verify-fnos-package.sh package.fpk}
|
||||
VERIFY_TMP_ROOT="${LIVERECORDER_VERIFY_TMPDIR:-${TMPDIR:-/tmp}}"
|
||||
mkdir -p "$VERIFY_TMP_ROOT"
|
||||
WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/liverecorder-fnos-verify.XXXXXX")
|
||||
trap 'rm -rf -- "$WORK_DIR"' EXIT
|
||||
|
||||
tar -xzf "$PACKAGE" -C "$WORK_DIR"
|
||||
grep -q '^appname=liverecorder$' "$WORK_DIR/manifest"
|
||||
grep -q '^version=1.0.0$' "$WORK_DIR/manifest"
|
||||
grep -q '^platform=x86$' "$WORK_DIR/manifest"
|
||||
test -x "$WORK_DIR/cmd/main"
|
||||
test -x "$WORK_DIR/cmd/install_callback"
|
||||
test -s "$WORK_DIR/wizard/install"
|
||||
test -s "$WORK_DIR/wizard/upgrade"
|
||||
test -s "$WORK_DIR/ICON.PNG"
|
||||
test -s "$WORK_DIR/ICON_256.PNG"
|
||||
|
||||
expected=$(sed -n 's/^checksum=//p' "$WORK_DIR/manifest")
|
||||
actual=$(md5sum "$WORK_DIR/app.tgz" | cut -d' ' -f1)
|
||||
test -n "$expected"
|
||||
test "$expected" = "$actual"
|
||||
|
||||
tar -tzf "$WORK_DIR/app.tgz" >"$WORK_DIR/app-files.txt"
|
||||
grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/bin/ffmpeg$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/bin/node$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/usr/bin/curl$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
|
||||
|
||||
if grep -Eq '^runtime/(usr/)?lib/x86_64-linux-gnu/(libc\.so\.6|ld-linux-x86-64\.so\.2)$' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'the FPK must use the fnOS glibc and matching system loader\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '^(data|records|postgres|log|var)/' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'persistent data must not be included in app.tgz\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'fnOS package verified: %s\n' "$PACKAGE"
|
||||
@@ -130,7 +130,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
return new SystemSettingsDto
|
||||
{
|
||||
FfmpegPath = GetValue(lookup, FfmpegPathKey, "ffmpeg"),
|
||||
OutputRoot = GetValue(lookup, OutputRootKey, "records"),
|
||||
OutputRoot = GetValue(lookup, OutputRootKey, GetDefaultOutputRoot()),
|
||||
OutputDirectoryTemplate = GetValue(lookup, OutputDirectoryTemplateKey, "{platform}/{yyyy}/{MM}/{dd}/{anchor}"),
|
||||
OutputFileNameTemplate = GetValue(lookup, OutputFileNameTemplateKey, "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"),
|
||||
DefaultQuality = GetValue(lookup, DefaultQualityKey, "origin"),
|
||||
@@ -292,6 +292,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDefaultOutputRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("LIVE_RECORDER_DEFAULT_OUTPUT_ROOT");
|
||||
return string.IsNullOrWhiteSpace(configured) ? "records" : configured.Trim();
|
||||
}
|
||||
|
||||
public async Task<SystemSettingsDto> UpdateAsync(UpdateSystemSettingsRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace LiveRecorder.Infrastructure.Persistence;
|
||||
|
||||
public sealed class DatabaseInitializer
|
||||
{
|
||||
private const string DefaultAdminPasswordEnvironmentVariable = "LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD";
|
||||
|
||||
private readonly LiveRecorderDbContext _dbContext;
|
||||
|
||||
public DatabaseInitializer(LiveRecorderDbContext dbContext)
|
||||
@@ -28,14 +30,16 @@ public sealed class DatabaseInitializer
|
||||
if (!await _dbContext.UserAccounts.AnyAsync(cancellationToken))
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var admin = new UserAccount("admin", "Administrator", PasswordHasher.Hash("Admin@123"), now);
|
||||
var passwordHash = PasswordHasher.Hash(GetDefaultAdminPassword());
|
||||
Environment.SetEnvironmentVariable(DefaultAdminPasswordEnvironmentVariable, null);
|
||||
var admin = new UserAccount("admin", "Administrator", passwordHash, now);
|
||||
await _dbContext.UserAccounts.AddAsync(admin, cancellationToken);
|
||||
}
|
||||
|
||||
var defaults = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ffmpeg.path"] = "ffmpeg",
|
||||
["recording.output_root"] = "records",
|
||||
["recording.output_root"] = GetDefaultOutputRoot(),
|
||||
["recording.output_directory_template"] = "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
|
||||
["recording.output_file_name_template"] = "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}",
|
||||
["recording.default_quality"] = "origin",
|
||||
@@ -167,4 +171,16 @@ public sealed class DatabaseInitializer
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string GetDefaultOutputRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("LIVE_RECORDER_DEFAULT_OUTPUT_ROOT");
|
||||
return string.IsNullOrWhiteSpace(configured) ? "records" : configured.Trim();
|
||||
}
|
||||
|
||||
private static string GetDefaultAdminPassword()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable(DefaultAdminPasswordEnvironmentVariable);
|
||||
return string.IsNullOrEmpty(configured) ? "Admin@123" : configured;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,14 +247,26 @@ builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
|
||||
builder.Services.AddHostedService<OpenListUploadBackgroundService>();
|
||||
|
||||
var app = builder.Build();
|
||||
var webRootPath = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||
var hasBundledFrontend = File.Exists(Path.Combine(webRootPath, "index.html"));
|
||||
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors("frontend");
|
||||
if (hasBundledFrontend)
|
||||
{
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
}
|
||||
|
||||
app.UseMiddleware<ApiTokenAuthenticationMiddleware>();
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/swagger"));
|
||||
if (!hasBundledFrontend)
|
||||
{
|
||||
app.MapGet("/", () => Results.Redirect("/swagger"));
|
||||
}
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// ── Health check endpoints ────────────────────────────────────────────
|
||||
@@ -359,6 +371,11 @@ if (resetRecordingData)
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasBundledFrontend)
|
||||
{
|
||||
app.MapFallbackToFile("index.html");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<Dictionary<string, long>> ReadRecordingDataCountsAsync(LiveRecorderDbContext dbContext)
|
||||
|
||||
Reference in New Issue
Block a user