first commit
This commit is contained in:
67
Dockerfile
Normal file
67
Dockerfile
Normal file
@@ -0,0 +1,67 @@
|
||||
# -----------------------------
|
||||
# NAS Media Player Dockerfile
|
||||
# -----------------------------
|
||||
FROM python:3.9-slim
|
||||
|
||||
LABEL maintainer="神雕<teasiu@qq.com>"
|
||||
LABEL version="1.2"
|
||||
LABEL description="NAS Media Player 多架构Python版Docker镜像"
|
||||
|
||||
# -----------------------------
|
||||
# 环境变量
|
||||
# -----------------------------
|
||||
ENV APP_DIR=/opt/nas-media-player \
|
||||
VIDEO_DIR=/mnt \
|
||||
PORT=8800 \
|
||||
LOG_FILE=/opt/nas-media-player/nas-media-player.log \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# -----------------------------
|
||||
# 创建目录
|
||||
# -----------------------------
|
||||
RUN mkdir -p ${APP_DIR}/static \
|
||||
&& mkdir -p ${VIDEO_DIR} \
|
||||
&& chmod 777 ${APP_DIR} ${VIDEO_DIR} ${APP_DIR}/static
|
||||
|
||||
# -----------------------------
|
||||
# 日志文件
|
||||
# -----------------------------
|
||||
RUN touch ${LOG_FILE} && chmod 666 ${LOG_FILE}
|
||||
|
||||
# -----------------------------
|
||||
# 安装依赖工具
|
||||
# -----------------------------
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# -----------------------------
|
||||
# 复制 requirements 并安装
|
||||
# -----------------------------
|
||||
COPY requirements.txt ${APP_DIR}/
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
-r ${APP_DIR}/requirements.txt
|
||||
|
||||
# -----------------------------
|
||||
# 复制程序和静态文件
|
||||
# -----------------------------
|
||||
COPY nas-media-player.py ${APP_DIR}/
|
||||
COPY index.html zhinan.html ${APP_DIR}/static/
|
||||
|
||||
# -----------------------------
|
||||
# 暴露端口
|
||||
# -----------------------------
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# -----------------------------
|
||||
# EntryPoint:直接运行 Python
|
||||
# -----------------------------
|
||||
RUN echo '#!/bin/sh' > /entrypoint.sh && \
|
||||
echo 'cd ${APP_DIR}' >> /entrypoint.sh && \
|
||||
echo 'echo "NAS Media Player 启动中... 端口:${PORT}"' >> /entrypoint.sh && \
|
||||
echo 'exec python3 nas-media-player.py >> ${LOG_FILE} 2>&1' >> /entrypoint.sh && \
|
||||
chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
112
README.md
Normal file
112
README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# NAS Media Player
|
||||
|
||||
轻量级NAS媒体播放器,专为多架构Linux系统设计(armhf/arm64/x86_64),完美兼容嵌入式设备(如hi3798mv100)和常规Ubuntu/Debian发行版,提供视频文件浏览、播放、上传、目录管理、私密目录保护等核心功能,开箱即用。
|
||||
|
||||
## 🌟 功能特性
|
||||
- **多架构适配**:自动识别armv7l(armhf)、aarch64(arm64)、x86_64架构,无需手动选择二进制文件
|
||||
- **核心功能**:视频文件浏览/播放、大文件上传、目录创建/删除、私密目录密码保护
|
||||
- **轻量化部署**:单脚本一键安装,自动配置systemd服务(开机自启)
|
||||
- **日志可视化**:运行日志固定存储在程序目录,便于问题排查
|
||||
- **兼容性强**:适配嵌入式设备(如hi3798mv100)和普通Linux服务器
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
## 一、海纳思系统安装方法
|
||||
> [!TIP]
|
||||
> 海纳思系统,直接如下安装即可
|
||||
> apt update && apt install -y nas-media-player
|
||||
> 忽略下面一切
|
||||
|
||||
## 二、其他ubuntu/debian系统
|
||||
> [!TIP]
|
||||
> 非海纳思系统,则推荐用以下命令快速运行容器,无需手动配置:
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
--name nas-media-player \
|
||||
-p 8800:8800 \
|
||||
-v ~/nas-media:/mnt \
|
||||
--restart always \
|
||||
slitazcn/nas-media-player:latest
|
||||
```
|
||||
|
||||
## 三、进阶者脚本安装
|
||||
### 1. 环境要求
|
||||
- 系统:Linux(Ubuntu/Debian/嵌入式Linux,支持systemd最佳)
|
||||
- 权限:需root权限(sudo)
|
||||
- 网络:克隆仓库需网络连通(部署后无网络也可使用)
|
||||
|
||||
### 2. 克隆仓库
|
||||
```bash
|
||||
git clone https://github.com/teasiu/nas-media-player.git
|
||||
cd nas-media-player
|
||||
```
|
||||
|
||||
### 3. 一键安装 & 启动
|
||||
安装脚本会自动完成「架构检测→文件部署→服务配置→启动运行」全流程:
|
||||
```bash
|
||||
sudo ./install.sh install
|
||||
```
|
||||
|
||||
### 4. 访问服务
|
||||
安装完成后,在浏览器中访问以下地址即可使用:
|
||||
```plaintext
|
||||
http://[你的设备IP]:8800
|
||||
```
|
||||
示例:`http://192.168.101.141:8800`
|
||||
|
||||
## ⚙️ 常用命令
|
||||
| 功能 | 执行命令 | 说明 |
|
||||
|--------------|---------------------------------------------------|-----------------------------------------|
|
||||
| 启动服务 | `sudo ./install.sh start` | 启动NAS Media Player服务 |
|
||||
| 停止服务 | `sudo ./install.sh stop` | 停止运行中的服务 |
|
||||
| 重启服务 | `sudo ./install.sh restart` | 重启服务(配置修改后生效) |
|
||||
| 查看状态 | `sudo ./install.sh status` | 查看服务运行状态、端口监听、目录状态 |
|
||||
| 查看日志 | `tail -f /opt/nas-media-player/nas-media-player.log` | 实时查看运行日志 |
|
||||
| 卸载服务 | `sudo ./install.sh uninstall` | 卸载程序(保留/mnt媒体目录文件) |
|
||||
| 查看帮助 | `sudo ./install.sh help` | 查看所有可用命令 |
|
||||
|
||||
## 🛠️ 配置说明
|
||||
核心配置可在`install.sh`脚本头部修改,无需改动代码:
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------------|-------------------------|---------------------------------------|
|
||||
| `APP_DIR` | `/opt/nas-media-player` | 程序安装目录 |
|
||||
| `PORT` | `8800` | 服务监听端口 |
|
||||
| `VIDEO_DIR` | `/mnt` | 媒体文件存储根目录 |
|
||||
| `LOG_FILE` | `${APP_DIR}/nas-media-player.log` | 运行日志文件路径 |
|
||||
|
||||
## ❓ 常见问题
|
||||
### Q1:安装后端口8800未监听?
|
||||
- 嵌入式设备启动可能有延迟,等待1分钟后重试;
|
||||
- 执行`sudo ./install.sh status`查看服务状态;
|
||||
- 查看日志排查:`tail -f /opt/nas-media-player/nas-media-player.log`。
|
||||
|
||||
### Q2:上传文件失败/目录创建报错?
|
||||
- 检查`/mnt`目录权限:`sudo chmod 777 /mnt`;
|
||||
- 确认磁盘空间充足,大文件上传建议使用有线网络。
|
||||
|
||||
### Q3:不支持的架构报错?
|
||||
- 仅支持armhf/arm64/x86_64架构,执行`uname -m`查看系统架构。
|
||||
|
||||
### Q4:卸载后重新安装失败?
|
||||
- 先执行`sudo ./install.sh uninstall`清理残留,再重新安装。
|
||||
|
||||
## 📂 目录结构
|
||||
```plaintext
|
||||
nas-media-player/
|
||||
├── install.sh # 一键安装/管理脚本
|
||||
├── nas-media-player.py # 主程序源码
|
||||
├── index.html # 前端页面
|
||||
├── zhinan.html # 帮助页面
|
||||
├── releases/ # 多架构二进制文件目录
|
||||
│ ├── nas-media-player-armhf
|
||||
│ ├── nas-media-player-arm64
|
||||
│ └── nas-media-player-x86_64
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
本项目采用 MIT 许可证 - 详见 `LICENSE` 文件。
|
||||
|
||||
## 🤝 贡献
|
||||
欢迎提交Issue反馈问题,或PR优化功能,提交前请确保脚本在多架构环境下测试通过。
|
||||
1286
index.html
Normal file
1286
index.html
Normal file
File diff suppressed because one or more lines are too long
512
install.sh
Executable file
512
install.sh
Executable file
@@ -0,0 +1,512 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# 配置中心
|
||||
# ==============================================================================
|
||||
readonly APP_DIR="/opt/nas-media-player"
|
||||
readonly SERVICE_NAME="nas-media-player"
|
||||
readonly WAIT_TIMEOUT=10
|
||||
readonly SYSTEMD_SERVICE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
readonly PORT=8800 # 服务监听端口
|
||||
readonly VIDEO_DIR="/mnt" # 媒体文件根目录(和程序逻辑对齐)
|
||||
readonly LOG_FILE="${APP_DIR}/${SERVICE_NAME}.log" # 日志文件固定在运行目录
|
||||
readonly PIP_MIRROR="https://mirrors.tencent.com/pypi/simple" # 网络检查镜像
|
||||
readonly BIN_NAME="${SERVICE_NAME}" # 运行目录下的二进制文件名(统一命名)
|
||||
|
||||
# 必需文件列表(包含所有架构二进制,但部署时仅复制匹配的)
|
||||
readonly REQUIRED_FILES=(
|
||||
"nas-media-player.py"
|
||||
"index.html"
|
||||
"zhinan.html"
|
||||
"releases/nas-media-player-armhf"
|
||||
"releases/nas-media-player-arm64"
|
||||
"releases/nas-media-player-x86_64"
|
||||
)
|
||||
|
||||
# 架构映射表(uname -m → 二进制后缀)
|
||||
declare -A ARCH_MAP=(
|
||||
["armv7l"]="armhf"
|
||||
["aarch64"]="arm64"
|
||||
["x86_64"]="x86_64"
|
||||
)
|
||||
|
||||
# ==============================================================================
|
||||
# 颜色与样式定义(提升用户体验)
|
||||
# ==============================================================================
|
||||
readonly COLOR_RESET="\033[0m"
|
||||
readonly COLOR_RED="\033[31m"
|
||||
readonly COLOR_GREEN="\033[32m"
|
||||
readonly COLOR_YELLOW="\033[33m"
|
||||
readonly COLOR_BLUE="\033[34m"
|
||||
readonly COLOR_BOLD="\033[1m"
|
||||
|
||||
# ==============================================================================
|
||||
# 全局变量(检测后赋值)
|
||||
# ==============================================================================
|
||||
OS_NAME="" # 系统发行版
|
||||
DETECTED_ARCH="" # 检测到的系统架构
|
||||
SOURCE_BIN_FILE="" # 源二进制文件(releases下的匹配文件)
|
||||
TARGET_BIN_FILE="" # 目标二进制文件(运行目录下)
|
||||
HAS_SYSTEMD="false" # 是否支持systemd
|
||||
|
||||
# ==============================================================================
|
||||
# 日志与输出函数(统一输出格式)
|
||||
# ==============================================================================
|
||||
log_info() {
|
||||
echo -e "${COLOR_BLUE}[INFO]${COLOR_RESET} $*"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${COLOR_GREEN}[SUCCESS]${COLOR_RESET} $*"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${COLOR_YELLOW}[WARN]${COLOR_RESET} $*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${COLOR_RED}[ERROR]${COLOR_RESET} $*" >&2
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "\n${COLOR_BOLD}[$1/$2] $3${COLOR_RESET}"
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 系统检测函数(核心:架构/发行版/systemd检测)
|
||||
# ==============================================================================
|
||||
detect_os_info() {
|
||||
log_step 1 7 "检测系统信息"
|
||||
|
||||
# 1. 检测系统发行版
|
||||
if [ -f /etc/os-release ]; then
|
||||
source /etc/os-release
|
||||
OS_NAME="${NAME} ${VERSION_ID}"
|
||||
elif [ -f /etc/lsb-release ]; then
|
||||
source /etc/lsb-release
|
||||
OS_NAME="${DISTRIB_ID} ${DISTRIB_RELEASE}"
|
||||
else
|
||||
OS_NAME="Unknown Linux"
|
||||
fi
|
||||
log_info "系统发行版:${OS_NAME}"
|
||||
|
||||
# 2. 检测系统架构并匹配二进制文件
|
||||
local raw_arch=$(uname -m)
|
||||
if [[ -v ARCH_MAP["${raw_arch}"] ]]; then
|
||||
DETECTED_ARCH="${ARCH_MAP["${raw_arch}"]}"
|
||||
SOURCE_BIN_FILE="releases/${SERVICE_NAME}-${DETECTED_ARCH}" # 源文件(脚本同目录)
|
||||
TARGET_BIN_FILE="${APP_DIR}/${BIN_NAME}" # 目标文件(运行目录)
|
||||
log_info "检测到架构:${raw_arch} → 匹配二进制:${SOURCE_BIN_FILE} → 部署到:${TARGET_BIN_FILE}"
|
||||
else
|
||||
log_error "不支持的架构:${raw_arch}(仅支持armhf/arm64/x86_64)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. 检测是否支持systemd
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl >/dev/null 2>&1; then
|
||||
HAS_SYSTEMD="true"
|
||||
log_info "系统支持systemd服务管理"
|
||||
else
|
||||
log_warn "系统不支持systemd,无法配置开机自启"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 前置检查函数
|
||||
# ==============================================================================
|
||||
check_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
log_error "请使用 root 用户执行(sudo -i 后运行)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_network() {
|
||||
log_step 2 7 "检查网络连通性"
|
||||
if ! curl -s --connect-timeout 5 "${PIP_MIRROR}" >/dev/null; then
|
||||
log_warn "网络连接可能不稳定(不影响本地部署)"
|
||||
else
|
||||
log_success "网络连通性检查通过"
|
||||
fi
|
||||
}
|
||||
|
||||
check_required_files() {
|
||||
log_step 3 7 "检查必需文件"
|
||||
local missing_files=()
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "${file}" ]; then
|
||||
missing_files+=("${file}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_files[@]} -gt 0 ]; then
|
||||
log_error "缺失必需文件:${missing_files[*]}"
|
||||
log_error "请确保所有必需文件与脚本同目录"
|
||||
exit 1
|
||||
fi
|
||||
log_success "所有必需文件检查通过"
|
||||
}
|
||||
|
||||
check_system_deps() {
|
||||
log_step 4 7 "检查系统依赖"
|
||||
# 检查必要命令
|
||||
local required_cmds=("curl" "awk" "grep" "pkill" "pgrep")
|
||||
for cmd in "${required_cmds[@]}"; do
|
||||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||||
log_error "缺失必需命令:${cmd}(请先安装)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
log_success "系统依赖检查通过"
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 核心功能函数
|
||||
# ==============================================================================
|
||||
create_app_dirs() {
|
||||
log_step 5 7 "创建应用目录"
|
||||
# 仅创建必要目录
|
||||
mkdir -p "${APP_DIR}" \
|
||||
"${APP_DIR}/static" \
|
||||
"${VIDEO_DIR}"
|
||||
|
||||
# 创建日志文件并设置权限(确保运行目录可写)
|
||||
touch "${LOG_FILE}"
|
||||
chmod 644 "${LOG_FILE}"
|
||||
chmod 755 "${APP_DIR}" # 确保运行目录有执行权限
|
||||
log_success "应用目录创建完成:${APP_DIR}(日志文件:${LOG_FILE})"
|
||||
}
|
||||
|
||||
deploy_app_files() {
|
||||
log_step 6 7 "部署程序文件"
|
||||
|
||||
# 复制主程序文件
|
||||
cp -f "nas-media-player.py" "${APP_DIR}/" || { log_error "复制主程序文件失败"; exit 1; }
|
||||
|
||||
# 复制静态文件
|
||||
cp -f "index.html" "zhinan.html" "${APP_DIR}/static/" || { log_error "复制静态文件失败"; exit 1; }
|
||||
|
||||
# 仅复制匹配架构的二进制文件到运行目录(核心改动)
|
||||
cp -f "${SOURCE_BIN_FILE}" "${TARGET_BIN_FILE}" || { log_error "复制二进制文件 ${SOURCE_BIN_FILE} 失败"; exit 1; }
|
||||
|
||||
# 给二进制文件加可执行权限(关键)
|
||||
chmod +x "${TARGET_BIN_FILE}" || { log_error "设置二进制文件可执行权限失败"; exit 1; }
|
||||
|
||||
# 验证部署
|
||||
if [ -f "${APP_DIR}/static/index.html" ] && [ -x "${TARGET_BIN_FILE}" ]; then
|
||||
log_success "程序文件部署完成:"
|
||||
log_success " - 主程序:${APP_DIR}/nas-media-player.py"
|
||||
log_success " - 静态文件:${APP_DIR}/static/"
|
||||
log_success " - 二进制文件:${TARGET_BIN_FILE}(${DETECTED_ARCH}架构)"
|
||||
log_success " - 日志文件:${LOG_FILE}"
|
||||
else
|
||||
log_error "文件部署失败!请检查 ${APP_DIR} 目录权限"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_systemd_service() {
|
||||
log_step 7 7 "配置系统服务(开机启动)"
|
||||
|
||||
if [ "${HAS_SYSTEMD}" != "true" ]; then
|
||||
log_warn "跳过systemd服务配置(系统不支持)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 写入服务文件(日志固定输出到运行目录,二进制路径为运行目录)
|
||||
cat > "${SYSTEMD_SERVICE}" <<EOF
|
||||
[Unit]
|
||||
Description=Lightweight NAS Media Player Service
|
||||
Documentation=https://github.com/teasiu/nas-media-player
|
||||
After=network.target network-online.target local-fs.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${TARGET_BIN_FILE}
|
||||
ExecReload=/bin/kill -HUP \$MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=30
|
||||
TimeoutStopSec=10
|
||||
LimitNOFILE=65535
|
||||
StandardOutput=append:${LOG_FILE}
|
||||
StandardError=append:${LOG_FILE}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 验证并启用服务
|
||||
if [ -f "${SYSTEMD_SERVICE}" ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}" >/dev/null 2>&1
|
||||
log_success "systemd服务配置完成(已启用开机启动)"
|
||||
else
|
||||
log_error "systemd服务文件创建失败"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查端口监听状态
|
||||
check_port_listen() {
|
||||
local port=$1
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulpn 2>/dev/null | grep -q ":${port}.*${BIN_NAME}"
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
netstat -tulpn 2>/dev/null | grep -q ":${port}.*${BIN_NAME}"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 启动服务
|
||||
start_service() {
|
||||
log_info "\n========== 启动服务 =========="
|
||||
|
||||
# 停止旧进程
|
||||
log_info "清理旧进程..."
|
||||
pkill -f "${TARGET_BIN_FILE}" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
|
||||
if [ "${HAS_SYSTEMD}" = "true" ]; then
|
||||
# systemd启动
|
||||
systemctl start "${SERVICE_NAME}"
|
||||
|
||||
# 等待服务启动
|
||||
log_info "等待服务启动(最长 ${WAIT_TIMEOUT} 秒)..."
|
||||
local counter=0
|
||||
while [ ${counter} -lt ${WAIT_TIMEOUT} ]; do
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||
if check_port_listen "${PORT}"; then
|
||||
log_success "服务启动成功(端口${PORT}已监听)"
|
||||
return 0
|
||||
else
|
||||
log_warn "服务已启动,但端口${PORT}未监听(嵌入式设备可能延迟,建议等待1分钟后重试)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
counter=$((counter + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 启动失败处理
|
||||
log_error "服务启动失败!"
|
||||
log_info "错误日志(最后20行):"
|
||||
tail -n 20 "${LOG_FILE}" || log_warn "无法读取日志文件:${LOG_FILE}"
|
||||
log_info "请检查服务状态:systemctl status ${SERVICE_NAME}"
|
||||
exit 1
|
||||
else
|
||||
# 非systemd启动(前台运行)
|
||||
log_warn "非systemd系统,将以前台方式启动服务(关闭终端则停止)"
|
||||
nohup "${TARGET_BIN_FILE}" > "${LOG_FILE}" 2>&1 &
|
||||
sleep 3
|
||||
if check_port_listen "${PORT}"; then
|
||||
log_success "服务前台启动成功(端口${PORT}已监听)"
|
||||
log_info "日志文件:${LOG_FILE}"
|
||||
else
|
||||
log_error "服务启动失败!请查看日志:${LOG_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
log_info "\n========== 停止服务 =========="
|
||||
|
||||
# systemd停止
|
||||
if [ "${HAS_SYSTEMD}" = "true" ] && systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||
systemctl stop "${SERVICE_NAME}"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# 强制清理残留进程
|
||||
pkill -9 -f "${TARGET_BIN_FILE}" >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
if ! pgrep -f "${TARGET_BIN_FILE}" >/dev/null; then
|
||||
log_success "服务已停止"
|
||||
else
|
||||
log_error "服务停止失败,请手动执行:pkill -9 -f '${TARGET_BIN_FILE}'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 获取本地IP(优先非回环IPv4)
|
||||
get_local_ip() {
|
||||
local ip
|
||||
ip=$(hostname -I | awk '{print $1}' | grep -v '^127.' | grep -v '^::') || \
|
||||
ip=$(ip addr show | grep 'inet ' | grep -v '127.0.0.1' | grep -v '::1' | head -n1 | awk '{print $2}' | cut -d'/' -f1) || \
|
||||
ip="127.0.0.1"
|
||||
echo "${ip}"
|
||||
}
|
||||
|
||||
# 安装完成总结
|
||||
show_install_summary() {
|
||||
local ip=$(get_local_ip)
|
||||
echo -e "\n${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "${COLOR_GREEN}🎉 ${SERVICE_NAME} 安装成功!${COLOR_RESET}"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "📍 访问地址:${COLOR_BLUE}http://${ip}:${PORT}${COLOR_RESET}"
|
||||
echo -e "📁 运行目录:${APP_DIR}"
|
||||
echo -e "🎬 媒体目录:${VIDEO_DIR}"
|
||||
echo -e "📜 日志文件:${LOG_FILE}(固定在运行目录)"
|
||||
echo -e "⚙️ 运行二进制:${TARGET_BIN_FILE}(${DETECTED_ARCH}架构)"
|
||||
echo -e "🔧 系统服务:${SERVICE_NAME}"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "✨ 功能特性:"
|
||||
echo -e " ✅ 自动匹配系统架构部署二进制文件"
|
||||
echo -e " ✅ 支持子目录浏览和播放"
|
||||
echo -e " ✅ 支持视频文件上传(大小不限)"
|
||||
echo -e " ✅ 支持创建新目录/私密目录"
|
||||
echo -e " ✅ 丝滑的上传进度条显示"
|
||||
echo -e " ✅ 支持MP4/AVI/MKV/WEBM等主流格式"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "📋 常用命令:"
|
||||
echo -e " 启动服务:${0} start 或 systemctl start ${SERVICE_NAME}"
|
||||
echo -e " 停止服务:${0} stop 或 systemctl stop ${SERVICE_NAME}"
|
||||
echo -e " 重启服务:${0} restart 或 systemctl restart ${SERVICE_NAME}"
|
||||
echo -e " 查看状态:${0} status 或 systemctl status ${SERVICE_NAME}"
|
||||
echo -e " 查看日志:tail -f ${LOG_FILE}(推荐)或 journalctl -u ${SERVICE_NAME} -f"
|
||||
echo -e " 卸载服务:${0} uninstall"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
}
|
||||
|
||||
# 卸载服务
|
||||
uninstall_service() {
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "${COLOR_YELLOW}开始卸载 ${SERVICE_NAME} 服务${COLOR_RESET}"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
|
||||
# 停止并清理systemd服务
|
||||
if [ "${HAS_SYSTEMD}" = "true" ] && [ -f "${SYSTEMD_SERVICE}" ]; then
|
||||
systemctl stop "${SERVICE_NAME}" >/dev/null 2>&1 || true
|
||||
systemctl disable "${SERVICE_NAME}" >/dev/null 2>&1 || true
|
||||
rm -f "${SYSTEMD_SERVICE}"
|
||||
systemctl daemon-reload
|
||||
log_success "systemd服务已清理"
|
||||
fi
|
||||
|
||||
# 停止残留进程
|
||||
stop_service >/dev/null 2>&1 || true
|
||||
|
||||
# 删除程序目录(包含日志文件)
|
||||
log_info "删除运行目录(含日志文件)..."
|
||||
rm -rf "${APP_DIR}" && log_success "运行目录 ${APP_DIR} 已删除"
|
||||
|
||||
# 保留媒体目录
|
||||
log_warn "媒体目录 ${VIDEO_DIR} 已保留(包含您的媒体文件)"
|
||||
|
||||
echo -e "\n${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "${COLOR_GREEN}✅ ${SERVICE_NAME} 服务卸载完成${COLOR_RESET}"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 显示帮助信息
|
||||
show_help() {
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "${COLOR_BLUE}${SERVICE_NAME} 安装管理脚本${COLOR_RESET}"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "使用方法:${0} [命令]"
|
||||
echo -e "\n可用命令:"
|
||||
echo -e " install - 安装并启动服务(核心命令)"
|
||||
echo -e " start - 启动服务"
|
||||
echo -e " stop - 停止服务"
|
||||
echo -e " restart - 重启服务"
|
||||
echo -e " status - 查看服务状态"
|
||||
echo -e " uninstall - 卸载服务(保留媒体文件)"
|
||||
echo -e " help - 显示此帮助信息"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 显示服务状态
|
||||
show_status() {
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "${COLOR_BLUE}${SERVICE_NAME} 服务状态${COLOR_RESET}"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
echo -e "服务名称:${SERVICE_NAME}"
|
||||
echo -e "运行架构:${DETECTED_ARCH:-未检测}"
|
||||
echo -e "运行二进制:${TARGET_BIN_FILE:-未知}"
|
||||
if [ "${HAS_SYSTEMD}" = "true" ]; then
|
||||
echo -e "运行状态:$(systemctl is-active --quiet "${SERVICE_NAME}" && echo -e "${COLOR_GREEN}运行中${COLOR_RESET}" || echo -e "${COLOR_RED}已停止${COLOR_RESET}")"
|
||||
else
|
||||
echo -e "运行状态:$(pgrep -f "${TARGET_BIN_FILE}" >/dev/null && echo -e "${COLOR_GREEN}运行中${COLOR_RESET}" || echo -e "${COLOR_RED}已停止${COLOR_RESET}")"
|
||||
fi
|
||||
echo -e "监听端口:$(check_port_listen "${PORT}" && echo -e "${COLOR_GREEN}${PORT}(已监听)${COLOR_RESET}" || echo -e "${COLOR_RED}${PORT}(未监听)${COLOR_RESET}")"
|
||||
echo -e "运行目录:${APP_DIR} ($([ -d "${APP_DIR}" ] && echo -e "${COLOR_GREEN}存在${COLOR_RESET}" || echo -e "${COLOR_RED}不存在${COLOR_RESET}"))"
|
||||
echo -e "媒体目录:${VIDEO_DIR} ($([ -d "${VIDEO_DIR}" ] && echo -e "${COLOR_GREEN}存在${COLOR_RESET}" || echo -e "${COLOR_RED}不存在${COLOR_RESET}"))"
|
||||
echo -e "日志文件:${LOG_FILE} ($([ -f "${LOG_FILE}" ] && echo -e "${COLOR_GREEN}存在${COLOR_RESET}" || echo -e "${COLOR_RED}不存在${COLOR_RESET}"))"
|
||||
echo -e "系统发行版:${OS_NAME:-未知}"
|
||||
echo -e "${COLOR_BOLD}========================================${COLOR_RESET}"
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 主函数(处理命令参数)
|
||||
# ==============================================================================
|
||||
main() {
|
||||
# 无参数时显示帮助
|
||||
if [ $# -eq 0 ]; then
|
||||
show_help
|
||||
fi
|
||||
|
||||
local cmd="$1"
|
||||
case "${cmd}" in
|
||||
install)
|
||||
check_root
|
||||
detect_os_info
|
||||
check_network
|
||||
check_required_files
|
||||
check_system_deps
|
||||
create_app_dirs
|
||||
deploy_app_files
|
||||
if [ "${HAS_SYSTEMD}" = "true" ]; then
|
||||
create_systemd_service
|
||||
fi
|
||||
start_service
|
||||
show_install_summary
|
||||
;;
|
||||
start)
|
||||
check_root
|
||||
detect_os_info
|
||||
start_service
|
||||
;;
|
||||
stop)
|
||||
check_root
|
||||
detect_os_info
|
||||
stop_service
|
||||
;;
|
||||
restart)
|
||||
check_root
|
||||
detect_os_info
|
||||
stop_service
|
||||
start_service
|
||||
;;
|
||||
status)
|
||||
detect_os_info
|
||||
show_status
|
||||
;;
|
||||
uninstall)
|
||||
check_root
|
||||
detect_os_info
|
||||
uninstall_service
|
||||
;;
|
||||
help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
log_error "无效命令:${cmd}(使用 ${0} help 查看帮助)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 执行入口
|
||||
# ==============================================================================
|
||||
main "$@"
|
||||
|
||||
756
nas-media-player.py
Normal file
756
nas-media-player.py
Normal file
@@ -0,0 +1,756 @@
|
||||
from fastapi import FastAPI, Request, Form, UploadFile, File, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import aiofiles
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
import unicodedata
|
||||
import socket
|
||||
|
||||
VIDEO_DIR = os.getenv("NAS_MEDIA_VIDEO_DIR", "/mnt")
|
||||
PORT = int(os.getenv("NAS_MEDIA_PORT", 8800))
|
||||
APP_DIR = os.getenv("NAS_MEDIA_APP_DIR", "/opt/nas-media-player")
|
||||
LOG_FILE = os.getenv("NAS_MEDIA_LOG_FILE", os.path.join(APP_DIR, "nas-media-player.log"))
|
||||
|
||||
|
||||
log_dir = os.path.dirname(LOG_FILE)
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_FILE),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 配置路径
|
||||
VIDEO_ROOT = Path(VIDEO_DIR).resolve()
|
||||
PASSWORD_FILE = Path(APP_DIR) / "dir_passwords.json"
|
||||
|
||||
def path_is_relative_to(path: Path, base: Path) -> bool:
|
||||
"""检查path是否是base的子路径(兼容Python 3.8-)"""
|
||||
try:
|
||||
path.relative_to(base)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def path_relative_to(path: Path, base: Path) -> str:
|
||||
"""获取path相对于base的路径(兼容Python 3.8-)"""
|
||||
try:
|
||||
return str(path.relative_to(base))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
app = FastAPI(title="NAS 轻量媒体播放器")
|
||||
|
||||
# 添加CORS中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 支持的格式定义
|
||||
SUPPORTED_VIDEO_FORMATS = {
|
||||
".mp4": "video/mp4",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".flv": "video/x-flv",
|
||||
".wmv": "video/x-ms-wmv",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".m4v": "video/x-m4v"
|
||||
}
|
||||
|
||||
SUPPORTED_IMAGE_FORMATS = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".webp": "image/webp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff"
|
||||
}
|
||||
|
||||
SUPPORTED_AUDIO_FORMATS = {
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
".aac": "audio/aac",
|
||||
".m4a": "audio/mp4",
|
||||
".wma": "audio/x-ms-wma",
|
||||
".ape": "audio/ape",
|
||||
".alac": "audio/alac"
|
||||
}
|
||||
|
||||
# 合并所有支持的格式
|
||||
SUPPORTED_FORMATS = {**SUPPORTED_VIDEO_FORMATS, **SUPPORTED_IMAGE_FORMATS, **SUPPORTED_AUDIO_FORMATS}
|
||||
SUPPORTED_EXTENSIONS = list(SUPPORTED_FORMATS.keys())
|
||||
|
||||
# 挂载静态文件
|
||||
app.mount("/static", StaticFiles(directory=Path(APP_DIR) / "static"), name="static")
|
||||
|
||||
def get_safe_cookie_key(dir_path: str) -> str:
|
||||
"""将目录路径转换为MD5哈希值,避免Cookie键名包含非法字符"""
|
||||
encoded_path = dir_path.encode('utf-8')
|
||||
md5_hash = hashlib.md5(encoded_path).hexdigest()
|
||||
return f"auth_{md5_hash}"
|
||||
|
||||
# 密码管理功能
|
||||
def init_password_file():
|
||||
"""初始化密码文件(修复目录创建+合法JSON写入)"""
|
||||
app_dir = Path(APP_DIR)
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not PASSWORD_FILE.exists():
|
||||
with open(PASSWORD_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump({}, f)
|
||||
else:
|
||||
try:
|
||||
with open(PASSWORD_FILE, 'r', encoding='utf-8') as f:
|
||||
json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
with open(PASSWORD_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump({}, f)
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""密码哈希"""
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
def save_directory_password(dir_path: str, password: str):
|
||||
"""保存目录密码"""
|
||||
init_password_file()
|
||||
with open(PASSWORD_FILE, 'r+') as f:
|
||||
data = json.load(f)
|
||||
data[dir_path] = {
|
||||
"password_hash": hash_password(password),
|
||||
"created_at": datetime.now().isoformat()
|
||||
}
|
||||
f.seek(0)
|
||||
json.dump(data, f, indent=2)
|
||||
f.truncate()
|
||||
|
||||
def get_directory_password(dir_path: str) -> Optional[str]:
|
||||
"""获取目录密码哈希"""
|
||||
init_password_file()
|
||||
if not PASSWORD_FILE.exists():
|
||||
return None
|
||||
with open(PASSWORD_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
return data.get(dir_path, {}).get("password_hash")
|
||||
|
||||
def check_directory_password(dir_path: str, password: str) -> bool:
|
||||
"""验证目录密码"""
|
||||
stored_hash = get_directory_password(dir_path)
|
||||
if not stored_hash:
|
||||
return True
|
||||
return stored_hash == hash_password(password)
|
||||
|
||||
def get_protected_directories() -> List[str]:
|
||||
"""获取所有受保护的目录"""
|
||||
init_password_file()
|
||||
with open(PASSWORD_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
return list(data.keys())
|
||||
|
||||
def is_protected_directory(dir_path: str) -> bool:
|
||||
"""检查目录是否受保护(修复路径匹配逻辑)"""
|
||||
if not dir_path:
|
||||
return False
|
||||
protected_dirs = get_protected_directories()
|
||||
dir_path_normalized = dir_path.replace(os.sep, '/').rstrip('/')
|
||||
protected_dirs_normalized = [pdir.replace(os.sep, '/').rstrip('/') for pdir in protected_dirs]
|
||||
|
||||
for pdir in protected_dirs_normalized:
|
||||
if dir_path_normalized == pdir or dir_path_normalized.startswith(f"{pdir}/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_top_protected_directory(dir_path: str) -> Optional[str]:
|
||||
"""获取目录所属的顶级受保护目录(兼容Python 3.8-)"""
|
||||
if not dir_path or not is_protected_directory(dir_path):
|
||||
return None
|
||||
|
||||
# 统一路径分隔符为/,便于匹配
|
||||
dir_path_normalized = dir_path.replace(os.sep, '/').rstrip('/')
|
||||
protected_dirs = get_protected_directories()
|
||||
protected_dirs_normalized = [pdir.replace(os.sep, '/').rstrip('/') for pdir in protected_dirs]
|
||||
|
||||
top_dir = None
|
||||
max_depth = -1
|
||||
|
||||
for pdir, pdir_original in zip(protected_dirs_normalized, protected_dirs):
|
||||
if dir_path_normalized == pdir or dir_path_normalized.startswith(f"{pdir}/"):
|
||||
depth = pdir.count('/')
|
||||
if top_dir is None or depth < max_depth:
|
||||
max_depth = depth
|
||||
top_dir = pdir_original
|
||||
|
||||
return top_dir
|
||||
|
||||
async def verify_dir_access(request: Request, dir_path: str) -> bool:
|
||||
"""验证目录访问权限(简化逻辑,避免误拦截)"""
|
||||
if not dir_path or not is_protected_directory(dir_path):
|
||||
return True
|
||||
|
||||
top_protected_dir = get_top_protected_directory(dir_path)
|
||||
if not top_protected_dir:
|
||||
return True
|
||||
|
||||
# 使用安全的Cookie键名
|
||||
cookie_key = get_safe_cookie_key(top_protected_dir)
|
||||
cookie_value = request.cookies.get(cookie_key)
|
||||
stored_hash = get_directory_password(top_protected_dir)
|
||||
|
||||
# 兼容Cookie不存在的情况
|
||||
if cookie_value and stored_hash and cookie_value == stored_hash:
|
||||
logger.info(f"目录访问验证通过: {dir_path} (Cookie认证)")
|
||||
return True
|
||||
|
||||
logger.warning(f"目录访问验证失败: {dir_path} (缺少有效Cookie)")
|
||||
return False
|
||||
|
||||
# 根路径返回前端页面
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def read_root():
|
||||
return FileResponse(str(Path(APP_DIR) / "static" / "index.html"))
|
||||
|
||||
# 安全检查路径(兼容Python 3.8及以下版本)
|
||||
def safe_join(base: Path, *paths) -> Path:
|
||||
try:
|
||||
decoded_paths = [urllib.parse.unquote(path) for path in paths]
|
||||
joined_path = base.joinpath(*decoded_paths).resolve()
|
||||
joined_path.relative_to(base)
|
||||
return joined_path
|
||||
except ValueError:
|
||||
logger.error(f"路径越权:{joined_path} 不在 {base} 范围内")
|
||||
raise HTTPException(status_code=403, detail="无效路径(越权访问)")
|
||||
except Exception as e:
|
||||
logger.error(f"Path security check failed: {e}")
|
||||
raise HTTPException(status_code=403, detail="Invalid path")
|
||||
|
||||
# 获取目录结构
|
||||
@app.get("/api/directories")
|
||||
async def get_directories():
|
||||
dirs = []
|
||||
protected_dirs = get_protected_directories()
|
||||
|
||||
def traverse_recursive_dirs(path: Path, rel_path: str = "") -> List[Dict]:
|
||||
items = []
|
||||
try:
|
||||
for dir in path.iterdir():
|
||||
if dir.is_dir() and not dir.name.startswith('.'):
|
||||
sub_rel = f"{rel_path}/{dir.name}" if rel_path else dir.name
|
||||
is_protected = is_protected_directory(sub_rel)
|
||||
items.append({
|
||||
"name": dir.name,
|
||||
"path": sub_rel,
|
||||
"type": "directory",
|
||||
"protected": is_protected,
|
||||
"children": traverse_recursive_dirs(dir, sub_rel)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Directory traversal error: {e}")
|
||||
return items
|
||||
|
||||
if VIDEO_ROOT.exists():
|
||||
dirs = traverse_recursive_dirs(VIDEO_ROOT)
|
||||
return {"directories": dirs}
|
||||
|
||||
|
||||
@app.post("/api/verify-dir-password")
|
||||
async def verify_dir_password(dir_path: str = Form(...), password: str = Form(...)):
|
||||
try:
|
||||
top_protected_dir = get_top_protected_directory(dir_path)
|
||||
if not top_protected_dir:
|
||||
return {"success": True, "message": "目录不受保护"}
|
||||
|
||||
if check_directory_password(top_protected_dir, password):
|
||||
cookie_key = get_safe_cookie_key(top_protected_dir)
|
||||
response = JSONResponse({"success": True, "message": "密码正确"})
|
||||
response.set_cookie(
|
||||
key=cookie_key,
|
||||
value=hash_password(password),
|
||||
max_age=3600,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax"
|
||||
)
|
||||
logger.info(f"目录密码验证成功: {top_protected_dir}")
|
||||
return response
|
||||
else:
|
||||
logger.warning(f"目录密码验证失败: {top_protected_dir}")
|
||||
return {"success": False, "message": "密码错误"}
|
||||
except Exception as e:
|
||||
logger.error(f"Password verification error: {e}")
|
||||
return {"success": False, "message": f"验证失败: {str(e)}"}
|
||||
|
||||
async def check_dir_access(dir_path: str, request: Request) -> bool:
|
||||
"""检查目录访问权限"""
|
||||
if not dir_path:
|
||||
return True
|
||||
|
||||
top_protected_dir = get_top_protected_directory(dir_path)
|
||||
if not top_protected_dir:
|
||||
return True
|
||||
|
||||
cookie_key = get_safe_cookie_key(top_protected_dir)
|
||||
cookie_value = request.cookies.get(cookie_key)
|
||||
stored_hash = get_directory_password(top_protected_dir)
|
||||
|
||||
if cookie_value and cookie_value == stored_hash:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@app.get("/api/media")
|
||||
async def get_media(subdir: Optional[str] = None, request: Request = None):
|
||||
try:
|
||||
if subdir and not await check_dir_access(subdir, request):
|
||||
return {
|
||||
"media": [],
|
||||
"current_dir": subdir or "",
|
||||
"protected": True,
|
||||
"top_protected_dir": get_top_protected_directory(subdir)
|
||||
}
|
||||
|
||||
if subdir and subdir.strip():
|
||||
target_dir = safe_join(VIDEO_ROOT, subdir.strip())
|
||||
else:
|
||||
target_dir = VIDEO_ROOT
|
||||
|
||||
if not target_dir.exists() or not target_dir.is_dir():
|
||||
return {"media": [], "current_dir": subdir or ""}
|
||||
|
||||
media = []
|
||||
|
||||
for file in target_dir.iterdir():
|
||||
if file.is_file():
|
||||
ext = file.suffix.lower()
|
||||
if ext in SUPPORTED_EXTENSIONS:
|
||||
if ext in SUPPORTED_VIDEO_FORMATS:
|
||||
file_type = "video"
|
||||
elif ext in SUPPORTED_AUDIO_FORMATS:
|
||||
file_type = "audio"
|
||||
else:
|
||||
file_type = "image"
|
||||
|
||||
media.append({
|
||||
"name": file.name,
|
||||
"type": file_type,
|
||||
"extension": ext,
|
||||
"size": file.stat().st_size,
|
||||
"modified": file.stat().st_mtime,
|
||||
"path": str(file)
|
||||
})
|
||||
|
||||
# 按文件名自然排序
|
||||
media.sort(key=lambda x: (len(x["name"]), x["name"]))
|
||||
logger.info(f"Found {len(media)} media files in {target_dir}")
|
||||
|
||||
return {
|
||||
"media": media,
|
||||
"current_dir": subdir or "",
|
||||
"protected": is_protected_directory(subdir or ""),
|
||||
"top_protected_dir": get_top_protected_directory(subdir or "")
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting media list: {e}")
|
||||
return {"media": [], "current_dir": subdir or "", "error": str(e)}
|
||||
|
||||
# 编码文件名用于HTTP头
|
||||
def encode_filename_for_header(filename: str) -> str:
|
||||
"""编码文件名以支持中文等特殊字符"""
|
||||
try:
|
||||
filename.encode('ascii')
|
||||
return filename
|
||||
except UnicodeEncodeError:
|
||||
return urllib.parse.quote(filename)
|
||||
|
||||
|
||||
@app.get("/api/media/{path:path}")
|
||||
async def serve_media(path: str, request: Request):
|
||||
try:
|
||||
decoded_path = urllib.parse.unquote(path)
|
||||
full_media_path = safe_join(VIDEO_ROOT, decoded_path)
|
||||
media_dir = path_relative_to(full_media_path.parent, VIDEO_ROOT) if path_is_relative_to(full_media_path.parent, VIDEO_ROOT) else str(full_media_path.parent)
|
||||
|
||||
if is_protected_directory(media_dir) and not await verify_dir_access(request, media_dir):
|
||||
raise HTTPException(status_code=403, detail="需要密码访问")
|
||||
|
||||
if not full_media_path.exists() or not full_media_path.is_file():
|
||||
logger.warning(f"Media file not found: {full_media_path}")
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": "Media file not found"}
|
||||
)
|
||||
|
||||
ext = full_media_path.suffix.lower()
|
||||
if ext not in SUPPORTED_EXTENSIONS:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": f"Unsupported format: {ext}"}
|
||||
)
|
||||
|
||||
mime_type = SUPPORTED_FORMATS.get(ext, "application/octet-stream")
|
||||
|
||||
# 处理图片
|
||||
if ext in SUPPORTED_IMAGE_FORMATS:
|
||||
logger.info(f"Serving image: {full_media_path}")
|
||||
|
||||
# 处理中文文件名的HTTP头
|
||||
filename = full_media_path.name
|
||||
encoded_filename = encode_filename_for_header(filename)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "max-age=3600",
|
||||
"Content-Disposition": f"inline; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
|
||||
return FileResponse(
|
||||
path=str(full_media_path),
|
||||
media_type=mime_type,
|
||||
filename=encoded_filename,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# 处理音频
|
||||
elif ext in SUPPORTED_AUDIO_FORMATS:
|
||||
logger.info(f"Serving audio: {full_media_path}")
|
||||
|
||||
# 处理中文文件名的HTTP头
|
||||
filename = full_media_path.name
|
||||
encoded_filename = encode_filename_for_header(filename)
|
||||
|
||||
headers = {
|
||||
"Content-Disposition": f"inline; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
|
||||
return FileResponse(
|
||||
path=str(full_media_path),
|
||||
media_type=mime_type,
|
||||
filename=encoded_filename,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# 视频处理断点续传
|
||||
file_size = full_media_path.stat().st_size
|
||||
range_header = request.headers.get("Range")
|
||||
|
||||
if range_header:
|
||||
try:
|
||||
range_str = range_header.split("=")[-1]
|
||||
start_str, end_str = range_str.split("-")
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else file_size - 1
|
||||
end = min(end, file_size - 1)
|
||||
start = max(0, start)
|
||||
except:
|
||||
start = 0
|
||||
end = min(1024*1024*2, file_size - 1)
|
||||
else:
|
||||
start = 0
|
||||
end = min(1024*1024*2, file_size - 1)
|
||||
|
||||
# 异步分块读取
|
||||
async def iterfile():
|
||||
async with aiofiles.open(str(full_media_path), 'rb') as f:
|
||||
await f.seek(start)
|
||||
remaining = end - start + 1
|
||||
while remaining > 0:
|
||||
chunk_size = min(1024*1024, remaining)
|
||||
chunk = await f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
remaining -= chunk_size
|
||||
|
||||
# 处理视频文件名
|
||||
filename = full_media_path.name
|
||||
encoded_filename = encode_filename_for_header(filename)
|
||||
|
||||
headers = {
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(end - start + 1),
|
||||
"Content-Type": mime_type,
|
||||
"Content-Disposition": f"inline; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
|
||||
logger.info(f"Serving video: {full_media_path} (bytes {start}-{end}/{file_size})")
|
||||
return StreamingResponse(
|
||||
iterfile(),
|
||||
status_code=206,
|
||||
headers=headers,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
except HTTPException as e:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving media: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"Server error: {str(e)}"}
|
||||
)
|
||||
|
||||
# 获取所有目录路径
|
||||
@app.get("/api/all-directories")
|
||||
async def get_all_directories():
|
||||
all_dirs = []
|
||||
|
||||
def traverse_all_dirs(path: Path, rel_path: str = ""):
|
||||
try:
|
||||
all_dirs.append({
|
||||
"name": rel_path if rel_path else "主目录",
|
||||
"path": rel_path,
|
||||
"protected": is_protected_directory(rel_path)
|
||||
})
|
||||
for dir in path.iterdir():
|
||||
if dir.is_dir() and not dir.name.startswith('.'):
|
||||
sub_rel = f"{rel_path}/{dir.name}" if rel_path else dir.name
|
||||
traverse_all_dirs(dir, sub_rel)
|
||||
except Exception as e:
|
||||
logger.error(f"Error traversing all directories: {e}")
|
||||
|
||||
if VIDEO_ROOT.exists():
|
||||
traverse_all_dirs(VIDEO_ROOT)
|
||||
return {"directories": all_dirs}
|
||||
|
||||
|
||||
@app.post("/api/create-directory")
|
||||
async def create_directory(
|
||||
target_path: str = Form(""),
|
||||
new_dir: str = Form(...),
|
||||
dir_password: Optional[str] = Form(None)
|
||||
):
|
||||
try:
|
||||
if not new_dir or new_dir.strip() == "":
|
||||
raise HTTPException(status_code=400, detail="目录名不能为空")
|
||||
|
||||
# 安全路径拼接
|
||||
if target_path and target_path.strip():
|
||||
parent_dir = safe_join(VIDEO_ROOT, target_path.strip())
|
||||
else:
|
||||
parent_dir = VIDEO_ROOT
|
||||
|
||||
# 新增:检查父目录是否存在且可写
|
||||
if not parent_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"父目录不存在: {parent_dir}")
|
||||
if not os.access(parent_dir, os.W_OK):
|
||||
raise HTTPException(status_code=403, detail=f"父目录无写入权限: {parent_dir}")
|
||||
|
||||
new_dir_path = parent_dir / new_dir.strip()
|
||||
new_dir_rel_path = path_relative_to(new_dir_path, VIDEO_ROOT) if path_is_relative_to(new_dir_path, VIDEO_ROOT) else str(new_dir_path)
|
||||
|
||||
# 检查目录名合法性
|
||||
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
if any(char in new_dir for char in invalid_chars):
|
||||
raise HTTPException(status_code=400, detail="目录名包含非法字符(/\:*?\"<>|)")
|
||||
|
||||
# 新增:检查目录是否已存在
|
||||
if new_dir_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"目录已存在: {new_dir_path.name}")
|
||||
|
||||
# 创建目录(增强异常捕获)
|
||||
try:
|
||||
new_dir_path.mkdir(parents=True, exist_ok=False)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail=f"创建目录失败:权限不足({new_dir_path})")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建目录失败:{str(e)}")
|
||||
|
||||
# 设置密码保护
|
||||
if dir_password and dir_password.strip():
|
||||
save_directory_password(new_dir_rel_path, dir_password.strip())
|
||||
logger.info(f"带密码保护的目录创建成功: {new_dir_path}")
|
||||
else:
|
||||
logger.info(f"目录创建成功: {new_dir_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"目录创建成功: {new_dir_path.name}" + ("(已设置密码保护)" if dir_password else ""),
|
||||
"path": new_dir_rel_path,
|
||||
"protected": bool(dir_password)
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"创建目录异常: {e}", exc_info=True)
|
||||
return {"success": False, "message": f"创建失败: {str(e)}"}
|
||||
|
||||
@app.post("/api/upload-media")
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
target_dir: str = Form(""),
|
||||
file: UploadFile = File(...)
|
||||
):
|
||||
try:
|
||||
logger.info(f"开始处理上传请求 - 目标目录: {target_dir}, 文件名: {file.filename}")
|
||||
|
||||
if is_protected_directory(target_dir) and not await verify_dir_access(request, target_dir):
|
||||
logger.warning(f"加密目录上传权限拒绝: {target_dir}")
|
||||
return {"success": False, "message": "无权访问该目录,请先验证密码"}
|
||||
|
||||
if not file or not file.filename:
|
||||
logger.warning("上传失败:未选择文件")
|
||||
return {"success": False, "message": "未选择文件"}
|
||||
|
||||
filename = file.filename
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
if file_ext not in SUPPORTED_EXTENSIONS:
|
||||
logger.warning(f"上传失败:不支持的文件格式 {file_ext}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"不支持的文件格式: {file_ext},支持的格式: {', '.join(SUPPORTED_EXTENSIONS)}"
|
||||
}
|
||||
|
||||
if target_dir.strip():
|
||||
upload_dir = safe_join(VIDEO_ROOT, target_dir.strip())
|
||||
else:
|
||||
upload_dir = VIDEO_ROOT
|
||||
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
logger.info(f"上传目录已确认: {upload_dir}")
|
||||
|
||||
file_path = upload_dir / filename
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
stem = Path(filename).stem
|
||||
new_filename = f"{stem}_{counter}{file_ext}"
|
||||
file_path = upload_dir / new_filename
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
async with aiofiles.open(str(file_path), 'wb') as f:
|
||||
content_length = 0
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
await f.write(chunk)
|
||||
content_length += len(chunk)
|
||||
|
||||
if not file_path.exists():
|
||||
raise Exception("文件保存失败:文件不存在")
|
||||
if file_path.stat().st_size != content_length:
|
||||
logger.warning(f"文件大小不一致 - 预期: {content_length}, 实际: {file_path.stat().st_size}")
|
||||
|
||||
# 确定文件类型
|
||||
if file_ext in SUPPORTED_VIDEO_FORMATS:
|
||||
file_type = "视频"
|
||||
elif file_ext in SUPPORTED_AUDIO_FORMATS:
|
||||
file_type = "音频"
|
||||
else:
|
||||
file_type = "图片"
|
||||
|
||||
logger.info(f"文件上传成功: {file_path} ({file_type}, {file_path.stat().st_size} bytes)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{file_type}文件 {file_path.name} 上传成功",
|
||||
"filename": file_path.name,
|
||||
"path": target_dir,
|
||||
"size": file_path.stat().st_size
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 清理不完整文件
|
||||
if file_path.exists() and file_path.stat().st_size == 0:
|
||||
file_path.unlink()
|
||||
logger.warning(f"清理空文件: {file_path}")
|
||||
raise Exception(f"保存文件失败: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"上传失败: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"上传失败: {str(e)}"
|
||||
}
|
||||
finally:
|
||||
# 确保文件句柄关闭
|
||||
try:
|
||||
await file.close()
|
||||
except Exception as e:
|
||||
logger.error(f"关闭文件句柄失败: {e}")
|
||||
|
||||
@app.post("/api/clear-dir-auth")
|
||||
async def clear_dir_auth(dir_path: str = Form(...)):
|
||||
try:
|
||||
top_protected_dir = get_top_protected_directory(dir_path)
|
||||
if not top_protected_dir:
|
||||
return {"success": True, "message": "目录不受保护"}
|
||||
|
||||
cookie_key = get_safe_cookie_key(top_protected_dir)
|
||||
response = JSONResponse({"success": True, "message": "已清除访问权限"})
|
||||
response.delete_cookie(cookie_key)
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Clear auth error: {e}")
|
||||
return {"success": False, "message": f"清除失败: {str(e)}"}
|
||||
|
||||
|
||||
@app.get("/api/protected-directories")
|
||||
async def get_protected_dirs():
|
||||
return {"protected_dirs": get_protected_directories()}
|
||||
|
||||
def create_listen_sockets(port: int) -> list:
|
||||
sockets = []
|
||||
|
||||
# ===== IPv4 =====
|
||||
sock4 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock4.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock4.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
sock4.bind(("0.0.0.0", port))
|
||||
sock4.listen(2048)
|
||||
sockets.append(sock4)
|
||||
logger.info(f"IPv4 监听: 0.0.0.0:{port}")
|
||||
|
||||
# ===== IPv6 =====
|
||||
try:
|
||||
sock6 = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
|
||||
sock6.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock6.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
# 关键:必须是 1
|
||||
sock6.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||
sock6.bind(("::", port))
|
||||
sock6.listen(2048)
|
||||
sockets.append(sock6)
|
||||
logger.info(f"IPv6 监听: [::]:{port}")
|
||||
except OSError as e:
|
||||
logger.warning(f"IPv6 不可用,仅启用 IPv4: {e}")
|
||||
|
||||
return sockets
|
||||
|
||||
def main():
|
||||
init_password_file()
|
||||
import uvicorn
|
||||
sockets = create_listen_sockets(PORT)
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
log_level="warning",
|
||||
workers=1,
|
||||
access_log=False,
|
||||
timeout_keep_alive=30
|
||||
)
|
||||
|
||||
server = uvicorn.Server(config)
|
||||
server.run(sockets=sockets)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
25
releases/README.md
Normal file
25
releases/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
## 打包二进制制作方法
|
||||
|
||||
```
|
||||
apt update && apt install -y python3 python3-pip python3-dev gcc g++ make libffi-dev libssl-dev patchelf
|
||||
pip_select.sh
|
||||
pip3 install fastapi uvicorn aiofiles python-multipart pyinstaller
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
pyinstaller --onefile --name=nas-media-player-armhf --distpath=dist --workpath=tmp --clean --exclude-module=tkinter --exclude-module=unittest --exclude-module=sqlite3 nas-media-player.py
|
||||
pyinstaller --onefile --name=nas-media-player-arm64 --distpath=dist --workpath=tmp --clean --exclude-module=tkinter --exclude-module=unittest --exclude-module=sqlite3 nas-media-player.py
|
||||
pyinstaller --onefile --name=nas-media-player-x86_64 --distpath=dist --workpath=tmp --clean --exclude-module=tkinter --exclude-module=unittest --exclude-module=sqlite3 nas-media-player.py
|
||||
|
||||
~/.local/bin/pyinstaller --onefile --name=nas-media-player-x86_64 --distpath=dist --workpath=tmp --clean --exclude-module=tkinter --exclude-module=unittest --exclude-module=sqlite3 nas-media-player.py
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
chmod +x dist/nas-media-player-x86_64
|
||||
|
||||
./dist/nas-media-player-x86_64
|
||||
```
|
||||
|
||||
|
||||
BIN
releases/nas-media-player-arm64
Executable file
BIN
releases/nas-media-player-arm64
Executable file
Binary file not shown.
BIN
releases/nas-media-player-armhf
Executable file
BIN
releases/nas-media-player-armhf
Executable file
Binary file not shown.
BIN
releases/nas-media-player-x86_64
Executable file
BIN
releases/nas-media-player-x86_64
Executable file
Binary file not shown.
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.122.0
|
||||
uvicorn==0.33.0
|
||||
aiofiles==24.1.0
|
||||
pydantic==2.10.6
|
||||
starlette==0.44.0
|
||||
typing_extensions==4.13.2
|
||||
anyio==4.5.2
|
||||
python-multipart==0.0.20
|
||||
477
zhinan.html
Normal file
477
zhinan.html
Normal file
@@ -0,0 +1,477 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NAS媒体播放器使用指南</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: "Microsoft YaHei", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #4285F4;
|
||||
}
|
||||
h1 {
|
||||
color: #4285F4;
|
||||
margin-bottom: 10px;
|
||||
font-size: 2.2em;
|
||||
}
|
||||
h2 {
|
||||
color: #2d5fc1;
|
||||
margin: 30px 0 18px;
|
||||
padding-left: 12px;
|
||||
border-left: 4px solid #4285F4;
|
||||
font-size: 1.6em;
|
||||
}
|
||||
h3 {
|
||||
color: #1a4088;
|
||||
margin: 20px 0 12px;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.section {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
ul, ol {
|
||||
margin-left: 35px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
code {
|
||||
background: #f0f0f0;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
pre {
|
||||
background: #f0f0f0;
|
||||
padding: 18px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 20px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.note {
|
||||
background: #e8f4f8;
|
||||
padding: 18px;
|
||||
border-left: 4px solid #4285F4;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
padding: 18px;
|
||||
border-left: 4px solid #ffc107;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.feature-icon {
|
||||
font-size: 22px;
|
||||
margin-right: 12px;
|
||||
color: #4285F4;
|
||||
margin-top: 3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.version-info {
|
||||
color: #666;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.restriction {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>NAS媒体播放器使用指南</h1>
|
||||
<p class="version-info">版本:v1.0 | 日期:2025年12月</p>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<h2>📋 目录</h2>
|
||||
<ol>
|
||||
<li><a href="#install">安装部署</a></li>
|
||||
<li><a href="#basic">基础功能使用</a></li>
|
||||
<li><a href="#advanced">高级功能使用</a></li>
|
||||
<li><a href="#manage">目录与文件管理</a></li>
|
||||
<li><a href="#faq">常见问题解答</a></li>
|
||||
<li><a href="#troubleshoot">故障排除</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="section" id="install">
|
||||
<h2>🔧 安装部署</h2>
|
||||
|
||||
<h3>1. 环境要求</h3>
|
||||
<ul>
|
||||
<li><strong>操作系统</strong>:Linux(推荐Ubuntu 20.04+/Debian 11+)</li>
|
||||
<li><strong>浏览器要求</strong>:Chrome 80+、Firefox 75+、Edge 80+、Safari 14+</li>
|
||||
<li><strong>硬件要求</strong>:CPU双核以上,内存1GB以上,硬盘剩余空间≥10GB(用于媒体存储)</li>
|
||||
</ul>
|
||||
|
||||
<h3>2. 详细安装步骤</h3>
|
||||
<ol>
|
||||
|
||||
<li>
|
||||
<strong>海纳思系统直接 ssh 后台安装</strong>
|
||||
<p>进入你的 ssh 后台终端</p>
|
||||
<pre># apt update
|
||||
# apt install -y nas-media-player
|
||||
<div class="warning">
|
||||
<strong>重要</strong>:请在安装过程中注意返回信息,确保正确安装完成
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<strong>启动服务</strong>
|
||||
<p>安装完成后,服务已经自动启动,可以通过命令查看状态:</p>
|
||||
<pre>systemctl status nas-media-player</pre>
|
||||
|
||||
</li>
|
||||
<li>
|
||||
<strong>访问系统</strong>
|
||||
<p>1. 局域网访问:输入 <code>http://[服务器局域网IP]:8800</code>(如<code>http://192.168.1.100:8800</code>)<br>
|
||||
2. 外网访问:需配置路由器端口映射(将8800端口映射到服务器内网IP),然后通过<code>http://[公网IP]:8800</code>访问</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3>3. 服务自启动配置</h3>
|
||||
<p>NAS重启后服务自动运行,已配置systemd服务:</p>
|
||||
<pre>/etc/systemd/system/nas-media-player.service</pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section" id="basic">
|
||||
<h2>🎬 基础功能使用</h2>
|
||||
|
||||
<div class="feature">
|
||||
<span class="feature-icon">▶️</span>
|
||||
<div>
|
||||
<h3>媒体播放操作</h3>
|
||||
<ol>
|
||||
<li>登录系统后,左侧/上方的目录选择下拉框中选择媒体文件所在的子目录</li>
|
||||
<li>系统会自动扫描该目录下的所有支持格式媒体文件,并显示在媒体选择下拉框中</li>
|
||||
<li>选择要播放的文件:
|
||||
<ul>
|
||||
<li>方式一:直接点击媒体选择下拉框中的目标文件</li>
|
||||
<li>方式二:双击媒体列表中的文件(若界面支持列表视图)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>播放控制:
|
||||
<ul>
|
||||
<li>视频/音频:使用播放器内置控件进行播放/暂停、进度拖拽、音量调节、全屏切换</li>
|
||||
<li>图片:点击图片区域可查看下一张/上一张,或使用键盘方向键切换</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<span class="feature-icon">🔄</span>
|
||||
<div>
|
||||
<h3>媒体切换功能</h3>
|
||||
<ul>
|
||||
<li><strong>上一个/下一个切换</strong>:点击播放器下方的「上一个」「下一个」按钮,或使用键盘快捷键(左箭头/右箭头)</li>
|
||||
<li><strong>直接选择切换</strong>:从「选择媒体」下拉框中直接选择目标文件,播放器会立即加载并播放</li>
|
||||
<li><strong>自动连播功能</strong>:视频/音频文件播放结束后,系统会自动加载并播放当前目录下的下一个文件(图片文件不支持自动连播)</li>
|
||||
<li><strong>目录切换记忆</strong>:切换目录后,系统会记忆上次在该目录中播放的文件位置</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<span class="feature-icon">📊</span>
|
||||
<div>
|
||||
<h3>媒体信息查看</h3>
|
||||
<p>播放媒体时,页面下方的信息栏会实时显示:</p>
|
||||
<ul>
|
||||
<li>当前播放文件的完整名称(含扩展名)</li>
|
||||
<li>媒体类型标识(📹视频 / 🎵音频 / 🖼️图片)</li>
|
||||
<li>当前目录下的媒体文件总数及已播放数量</li>
|
||||
<li>文件大小信息(部分格式支持)</li>
|
||||
<li>当前播放进度百分比</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="advanced">
|
||||
<h2>⚙️ 高级功能使用</h2>
|
||||
|
||||
<h3>加密目录访问控制</h3>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>访问加密目录</strong>
|
||||
<ul>
|
||||
<li>当选择带有密码保护的目录时,系统会自动弹出密码验证弹窗</li>
|
||||
<li>在弹窗中输入该目录的访问密码,点击「确认」按钮</li>
|
||||
<li>密码验证成功后,弹窗关闭并加载该目录下的媒体文件列表</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>密码有效期</strong>
|
||||
<ul>
|
||||
<li>验证成功后,浏览器会保存认证Cookie,有效期为<strong>1小时</strong></li>
|
||||
<li>有效期内无需重复输入密码即可访问该加密目录及其子目录</li>
|
||||
<li>关闭浏览器或清除Cookie后,需重新验证密码</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>退出加密目录访问</strong>
|
||||
<ul>
|
||||
<li>方式一:关闭当前浏览器窗口/标签页</li>
|
||||
<li>方式二:清除浏览器Cookie(找到名称以<code>auth_</code>开头的Cookie并删除)</li>
|
||||
<li>方式三:等待1小时Cookie自动过期</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="warning">
|
||||
<strong>安全警告</strong>:<br>
|
||||
1. 请勿将加密目录密码告知无关人员<br>
|
||||
2. 建议定期更换重要目录的访问密码<br>
|
||||
3. 公共网络环境下使用后请及时退出加密目录访问
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="manage">
|
||||
<h2>📁 目录与文件管理</h2>
|
||||
|
||||
<h3>1. 目录创建与管理</h3>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>创建新子目录</strong>
|
||||
<ol>
|
||||
<li>在页面下方「创建新目录」区域操作:</li>
|
||||
<li>选择<strong>父目录</strong>:从下拉框中选择新目录的上级目录(只能选择已存在的非加密目录或已验证的加密目录)</li>
|
||||
<li>输入<strong>新目录名称</strong>:仅支持字母、数字、下划线、中横线,长度2-30字符,不能包含特殊符号(如/ \ : * ? " < > |)</li>
|
||||
<li>(可选)设置<strong>目录访问密码</strong>:输入6-16位密码(建议包含字母+数字),留空则不加密</li>
|
||||
<li>点击「创建目录」按钮,系统会提示创建结果</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>
|
||||
<strong>目录命名规则</strong>
|
||||
<ul>
|
||||
<li>不能与同级目录重名</li>
|
||||
<li>不能以点号(.)开头(避免创建隐藏目录)</li>
|
||||
<li>建议使用清晰的命名便于管理(如「电影-科幻」「音乐-流行」「照片-2025」)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3>2. 文件上传操作</h3>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>基础上传步骤</strong>
|
||||
<ol>
|
||||
<li>在「文件上传」区域选择<strong>目标目录</strong>:从下拉框中选择要上传到的子目录(<span class="restriction">注意:主目录(根目录)不支持文件上传,必须选择子目录</span>)</li>
|
||||
<li>点击「选择文件」按钮,在弹出的文件选择窗口中选择要上传的媒体文件(单次可选择多个文件)</li>
|
||||
<li>确认文件选择后,点击「上传」按钮开始上传</li>
|
||||
<li>上传过程中可查看:
|
||||
<ul>
|
||||
<li>进度条:显示当前文件的上传进度</li>
|
||||
<li>速度显示:实时上传速度(如1.2MB/s)</li>
|
||||
<li>剩余时间:预估完成时间</li>
|
||||
<li>文件列表:当前上传的文件名及大小</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>上传完成后,系统会提示「上传成功」,并自动刷新目标目录的媒体列表</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>
|
||||
<strong>支持的文件格式</strong>
|
||||
<ul>
|
||||
<li><strong>视频文件</strong>:mp4、avi、mkv、mov、flv、wmv、m4v、mpeg、mpg</li>
|
||||
<li><strong>音频文件</strong>:mp3、wav、flac、aac、m4a、ogg、wma、ape</li>
|
||||
<li><strong>图片文件</strong>:jpg、jpeg、png、gif、bmp、webp、tiff、tif</li>
|
||||
</ul>
|
||||
<div class="note">
|
||||
<strong>提示</strong>:建议优先使用通用格式(如mp4视频、mp3音频、jpg/png图片),兼容性最佳
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<strong>加密目录上传</strong>
|
||||
<ol>
|
||||
<li>选择加密目录作为目标上传目录</li>
|
||||
<li>若未验证该目录密码,系统会先弹出密码验证弹窗</li>
|
||||
<li>输入正确密码并验证通过后,方可进行上传操作</li>
|
||||
<li>上传完成后,文件会保存在加密目录内,仅授权用户可访问</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>
|
||||
<strong>上传限制与注意事项</strong>
|
||||
<ul>
|
||||
<li><span class="restriction">主目录(根目录)禁止上传文件</span>:所有文件必须上传到子目录中,便于分类管理</li>
|
||||
<li>单文件大小限制:默认无硬性限制(受服务器磁盘空间和网络带宽影响),建议单文件不超过2GB(大文件推荐分卷压缩后上传)</li>
|
||||
<li>批量上传数量:建议单次上传不超过10个文件,避免浏览器卡顿</li>
|
||||
<li>网络要求:上传大文件时建议使用有线网络(WiFi可能因信号不稳定导致上传中断)</li>
|
||||
<li>文件覆盖规则:若上传的文件名与目标目录中现有文件重名,会直接覆盖原有文件(请谨慎操作)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="section" id="faq">
|
||||
<h2>❓ 常见问题解答</h2>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="30%">问题</th>
|
||||
<th>解答</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>为什么主目录不能上传文件?</td>
|
||||
<td>为了便于媒体文件分类管理,系统设计为仅允许在子目录中上传文件,避免主目录文件杂乱无章,同时降低误操作风险。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>支持哪些媒体格式?</td>
|
||||
<td>视频:mp4、avi、mkv、mov等;音频:mp3、wav、flac等;图片:jpg、png、gif等。具体兼容性取决于浏览器,推荐使用mp4/mp3/jpg/png等通用格式。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>如何修改媒体根目录?</td>
|
||||
<td>编辑后端主文件(/opt/nas-media-player/main.py)中的<code>VIDEO_ROOT</code>变量,修改为新的目录路径,保存后重启服务即可生效(需确保新目录有读写权限)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>加密目录的密码忘记了怎么办?</td>
|
||||
<td>直接删除该目录下的<code>/opt/nas-media-player/dir_passwords.json</code>文件(隐藏文件),删除后该目录将失去密码保护,可重新设置新密码。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>可以在手机/平板上使用吗?</td>
|
||||
<td>可以,系统支持移动端适配,使用手机/平板的现代浏览器访问<code>http://[服务器IP]:8800</code>即可,操作逻辑与电脑端一致。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>上传的文件在哪里可以找到?</td>
|
||||
<td>上传的文件会保存在服务器的<code>VIDEO_ROOT</code>配置目录下的对应子目录中,可直接通过文件管理器访问服务器上的该目录查看。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>为什么部分视频文件无法播放?</td>
|
||||
<td>可能原因:1. 浏览器不支持该视频编码格式;2. 文件损坏;3. 缺少FFmpeg组件。建议安装FFmpeg或转换视频格式为mp4(H.264编码)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>如何修改服务端口(非8800)?</td>
|
||||
<td>启动服务时修改端口参数,如<code>/opt/nas-media-player/main.py</code>(改为8888端口),同时需确保新端口未被占用且防火墙已开放。</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section" id="troubleshoot">
|
||||
<h2>🛠️ 故障排除</h2>
|
||||
|
||||
<h3>1. 无法访问系统(页面无法打开)</h3>
|
||||
<ul>
|
||||
<li>检查服务是否正常运行:
|
||||
<ul>
|
||||
<li>Linux:<code>sudo systemctl status nas-media-player</code> 或<code>ps aux | grep uvicorn</code></li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
<li>确认端口8800是否被占用:
|
||||
<ul>
|
||||
<li>Linux:<code>netstat -tulpn | grep 8800</code> 或 <code>lsof -i:8800</code></li>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
<li>检查防火墙设置:
|
||||
<ul>
|
||||
<li>Linux:确保ufw/iptables已开放8800端口(<code>sudo ufw allow 8800</code>)</li>
|
||||
<li>路由器:外网访问需在路由器中配置端口映射(8800端口映射到服务器内网IP)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>确认访问地址是否正确:应为<code>http://[服务器IP]:8800</code>,注意区分http/https(默认使用http)</li>
|
||||
</ul>
|
||||
|
||||
<h3>2. 媒体文件无法播放</h3>
|
||||
<ul>
|
||||
<li>检查文件路径:确认文件存在于配置的媒体目录中,且路径中无中文/特殊字符(部分系统对中文路径支持不佳)</li>
|
||||
<li>检查文件权限:需确保文件有读权限(<code>chmod 644 [文件名]</code>)</li>
|
||||
<li>检查文件完整性:重新上传文件或在本地验证文件是否可正常播放</li>
|
||||
<li>查看浏览器控制台错误:按F12打开开发者工具,切换到Console标签,查看具体错误信息(如格式不支持、跨域问题等)</li>
|
||||
<li>尝试更换浏览器:部分格式在特定浏览器中兼容性较差,建议使用Chrome测试</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. 无法上传文件</h3>
|
||||
<ul>
|
||||
<li>确认是否选择了子目录:<span class="restriction">主目录不支持上传,必须选择子目录</span></li>
|
||||
<li>检查目标目录权限:Linux需确保目录有写权限(<code>chmod 755 [目录名]</code>)</li>
|
||||
<li>检查文件大小:若文件过大(如超过10GB),可能因网络或服务器配置导致上传失败,建议分卷上传</li>
|
||||
<li>检查网络连接:上传过程中网络中断会导致失败,可尝试缩短上传文件大小或更换网络</li>
|
||||
<li>加密目录上传失败:确认已正确输入密码并通过验证,Cookie未过期</li>
|
||||
</ul>
|
||||
|
||||
<h3>4. 加密目录验证失败</h3>
|
||||
<ul>
|
||||
<li>确认输入的密码是否正确(区分大小写)</li>
|
||||
<li>清除浏览器Cookie后重新验证:打开浏览器设置→隐私和安全→清除浏览数据→选择Cookie和其他网站数据,清除后重新访问</li>
|
||||
<li>检查<code>/opt/nas-media-player/dir_passwords.json</code>文件:确认加密目录下的<code>dir_passwords.json</code>文件存在且格式正确(由系统自动生成,请勿手动修改)</li>
|
||||
<li>验证服务器时间:若服务器时间与客户端时间差异过大(超过1小时),可能导致Cookie验证失败,需同步服务器时间</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. 创建目录失败</h3>
|
||||
<ul>
|
||||
<li>检查目录名称:是否包含特殊字符、长度是否符合要求(2-30字符)</li>
|
||||
<li>检查父目录权限:是否有在父目录中创建子目录的权限</li>
|
||||
<li>检查目录是否重名:同级目录中是否已存在同名目录</li>
|
||||
<li>检查磁盘空间:服务器磁盘是否已满,无剩余空间无法创建新目录</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📞 技术支持</h2>
|
||||
<p>如遇到使用问题或功能建议,请通过以下方式获取支持:</p>
|
||||
<ul>
|
||||
<li><strong>邮箱支持</strong>:teasiu@qq.com</li>
|
||||
<li><strong>社区论坛</strong>:NAS媒体播放器官方论坛(bbs.histb.com)</li>
|
||||
|
||||
</ul>
|
||||
<p style="margin-top: 25px; text-align: center; color: #666; font-size: 1.1em;">
|
||||
© 2025 海纳思 版权所有 | 保留所有权利
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user