first commit

This commit is contained in:
teasiu
2026-04-19 05:07:07 +08:00
commit 734c00f494
11 changed files with 3243 additions and 0 deletions

67
Dockerfile Normal file
View 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
View 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. 环境要求
- 系统LinuxUbuntu/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

File diff suppressed because one or more lines are too long

512
install.sh Executable file
View 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
View 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
View 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

Binary file not shown.

BIN
releases/nas-media-player-armhf Executable file

Binary file not shown.

BIN
releases/nas-media-player-x86_64 Executable file

Binary file not shown.

8
requirements.txt Normal file
View 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
View 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或转换视频格式为mp4H.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>