commit 734c00f494851a3f8c1f3738cb7c76fbf809f44d Author: teasiu Date: Sun Apr 19 05:07:07 2026 +0800 first commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d5cb0ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# ----------------------------- +# NAS Media Player Dockerfile +# ----------------------------- +FROM python:3.9-slim + +LABEL maintainer="神雕" +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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ff5fce --- /dev/null +++ b/README.md @@ -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优化功能,提交前请确保脚本在多架构环境下测试通过。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..c4e1498 --- /dev/null +++ b/index.html @@ -0,0 +1,1286 @@ + + + + + + NAS 轻量媒体播放器 + + + + + +
+
+
+

NAS 轻量媒体播放器

+ + +
+ +
+ + + 图片预览 +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ 当前目录:/ + +
+
+ 当前播放: + | + 共 0 个媒体文件 +
+
+ + +
+

媒体文件上传

+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
+
+ 0% + 0 MB / 0 MB + 0 KB/s +
+
+
+ +
+

创建新目录

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ + + + +
+ 支持格式:.mp4,.avi,.mkv,.webm,.mov,.flv,.wmv,.mpeg,.mpg,.m4v,.jpg,.jpeg,.png,.gif,.bmp,.webp,.tiff,.tif,.mp3,.wav,.ogg,.flac,.aac,.m4a,.wma,.ape,.alac +
+ + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1dc2ff5 --- /dev/null +++ b/install.sh @@ -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}" </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 "$@" + diff --git a/nas-media-player.py b/nas-media-player.py new file mode 100644 index 0000000..ab089bb --- /dev/null +++ b/nas-media-player.py @@ -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() + diff --git a/releases/README.md b/releases/README.md new file mode 100644 index 0000000..57ca204 --- /dev/null +++ b/releases/README.md @@ -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 +``` + + diff --git a/releases/nas-media-player-arm64 b/releases/nas-media-player-arm64 new file mode 100755 index 0000000..11cce91 Binary files /dev/null and b/releases/nas-media-player-arm64 differ diff --git a/releases/nas-media-player-armhf b/releases/nas-media-player-armhf new file mode 100755 index 0000000..eabf4fd Binary files /dev/null and b/releases/nas-media-player-armhf differ diff --git a/releases/nas-media-player-x86_64 b/releases/nas-media-player-x86_64 new file mode 100755 index 0000000..318d380 Binary files /dev/null and b/releases/nas-media-player-x86_64 differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ec9b649 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/zhinan.html b/zhinan.html new file mode 100644 index 0000000..b633998 --- /dev/null +++ b/zhinan.html @@ -0,0 +1,477 @@ + + + + + + NAS媒体播放器使用指南 + + + +
+

NAS媒体播放器使用指南

+

版本:v1.0 | 日期:2025年12月

+
+ + + +
+

🔧 安装部署

+ +

1. 环境要求

+
    +
  • 操作系统:Linux(推荐Ubuntu 20.04+/Debian 11+)
  • +
  • 浏览器要求:Chrome 80+、Firefox 75+、Edge 80+、Safari 14+
  • +
  • 硬件要求:CPU双核以上,内存1GB以上,硬盘剩余空间≥10GB(用于媒体存储)
  • +
+ +

2. 详细安装步骤

+
    + +
  1. + 海纳思系统直接 ssh 后台安装 +

    进入你的 ssh 后台终端

    +
    # apt update
    +# apt install -y nas-media-player
    +                
    + 重要:请在安装过程中注意返回信息,确保正确安装完成 +
    +
  2. +
  3. + 启动服务 +

    安装完成后,服务已经自动启动,可以通过命令查看状态:

    +
    systemctl status nas-media-player
    + +
  4. +
  5. + 访问系统 +

    1. 局域网访问:输入 http://[服务器局域网IP]:8800(如http://192.168.1.100:8800
    + 2. 外网访问:需配置路由器端口映射(将8800端口映射到服务器内网IP),然后通过http://[公网IP]:8800访问

    +
  6. +
+ +

3. 服务自启动配置

+

NAS重启后服务自动运行,已配置systemd服务:

+
/etc/systemd/system/nas-media-player.service
+ +
+ +
+

🎬 基础功能使用

+ +
+ ▶️ +
+

媒体播放操作

+
    +
  1. 登录系统后,左侧/上方的目录选择下拉框中选择媒体文件所在的子目录
  2. +
  3. 系统会自动扫描该目录下的所有支持格式媒体文件,并显示在媒体选择下拉框中
  4. +
  5. 选择要播放的文件: +
      +
    • 方式一:直接点击媒体选择下拉框中的目标文件
    • +
    • 方式二:双击媒体列表中的文件(若界面支持列表视图)
    • +
    +
  6. +
  7. 播放控制: +
      +
    • 视频/音频:使用播放器内置控件进行播放/暂停、进度拖拽、音量调节、全屏切换
    • +
    • 图片:点击图片区域可查看下一张/上一张,或使用键盘方向键切换
    • +
    +
  8. +
+
+
+ +
+ 🔄 +
+

媒体切换功能

+
    +
  • 上一个/下一个切换:点击播放器下方的「上一个」「下一个」按钮,或使用键盘快捷键(左箭头/右箭头)
  • +
  • 直接选择切换:从「选择媒体」下拉框中直接选择目标文件,播放器会立即加载并播放
  • +
  • 自动连播功能:视频/音频文件播放结束后,系统会自动加载并播放当前目录下的下一个文件(图片文件不支持自动连播)
  • +
  • 目录切换记忆:切换目录后,系统会记忆上次在该目录中播放的文件位置
  • +
+
+
+ +
+ 📊 +
+

媒体信息查看

+

播放媒体时,页面下方的信息栏会实时显示:

+
    +
  • 当前播放文件的完整名称(含扩展名)
  • +
  • 媒体类型标识(📹视频 / 🎵音频 / 🖼️图片)
  • +
  • 当前目录下的媒体文件总数及已播放数量
  • +
  • 文件大小信息(部分格式支持)
  • +
  • 当前播放进度百分比
  • +
+
+
+
+ +
+

⚙️ 高级功能使用

+ +

加密目录访问控制

+
    +
  1. + 访问加密目录 +
      +
    • 当选择带有密码保护的目录时,系统会自动弹出密码验证弹窗
    • +
    • 在弹窗中输入该目录的访问密码,点击「确认」按钮
    • +
    • 密码验证成功后,弹窗关闭并加载该目录下的媒体文件列表
    • +
    +
  2. +
  3. + 密码有效期 +
      +
    • 验证成功后,浏览器会保存认证Cookie,有效期为1小时
    • +
    • 有效期内无需重复输入密码即可访问该加密目录及其子目录
    • +
    • 关闭浏览器或清除Cookie后,需重新验证密码
    • +
    +
  4. +
  5. + 退出加密目录访问 +
      +
    • 方式一:关闭当前浏览器窗口/标签页
    • +
    • 方式二:清除浏览器Cookie(找到名称以auth_开头的Cookie并删除)
    • +
    • 方式三:等待1小时Cookie自动过期
    • +
    +
  6. +
+ +
+ 安全警告
+ 1. 请勿将加密目录密码告知无关人员
+ 2. 建议定期更换重要目录的访问密码
+ 3. 公共网络环境下使用后请及时退出加密目录访问 +
+
+ +
+

📁 目录与文件管理

+ +

1. 目录创建与管理

+
    +
  1. + 创建新子目录 +
      +
    1. 在页面下方「创建新目录」区域操作:
    2. +
    3. 选择父目录:从下拉框中选择新目录的上级目录(只能选择已存在的非加密目录或已验证的加密目录)
    4. +
    5. 输入新目录名称:仅支持字母、数字、下划线、中横线,长度2-30字符,不能包含特殊符号(如/ \ : * ? " < > |)
    6. +
    7. (可选)设置目录访问密码:输入6-16位密码(建议包含字母+数字),留空则不加密
    8. +
    9. 点击「创建目录」按钮,系统会提示创建结果
    10. +
    +
  2. +
  3. + 目录命名规则 +
      +
    • 不能与同级目录重名
    • +
    • 不能以点号(.)开头(避免创建隐藏目录)
    • +
    • 建议使用清晰的命名便于管理(如「电影-科幻」「音乐-流行」「照片-2025」)
    • +
    +
  4. +
+ +

2. 文件上传操作

+
    +
  1. + 基础上传步骤 +
      +
    1. 在「文件上传」区域选择目标目录:从下拉框中选择要上传到的子目录(注意:主目录(根目录)不支持文件上传,必须选择子目录
    2. +
    3. 点击「选择文件」按钮,在弹出的文件选择窗口中选择要上传的媒体文件(单次可选择多个文件)
    4. +
    5. 确认文件选择后,点击「上传」按钮开始上传
    6. +
    7. 上传过程中可查看: +
        +
      • 进度条:显示当前文件的上传进度
      • +
      • 速度显示:实时上传速度(如1.2MB/s)
      • +
      • 剩余时间:预估完成时间
      • +
      • 文件列表:当前上传的文件名及大小
      • +
      +
    8. +
    9. 上传完成后,系统会提示「上传成功」,并自动刷新目标目录的媒体列表
    10. +
    +
  2. +
  3. + 支持的文件格式 +
      +
    • 视频文件:mp4、avi、mkv、mov、flv、wmv、m4v、mpeg、mpg
    • +
    • 音频文件:mp3、wav、flac、aac、m4a、ogg、wma、ape
    • +
    • 图片文件:jpg、jpeg、png、gif、bmp、webp、tiff、tif
    • +
    +
    + 提示:建议优先使用通用格式(如mp4视频、mp3音频、jpg/png图片),兼容性最佳 +
    +
  4. +
  5. + 加密目录上传 +
      +
    1. 选择加密目录作为目标上传目录
    2. +
    3. 若未验证该目录密码,系统会先弹出密码验证弹窗
    4. +
    5. 输入正确密码并验证通过后,方可进行上传操作
    6. +
    7. 上传完成后,文件会保存在加密目录内,仅授权用户可访问
    8. +
    +
  6. +
  7. + 上传限制与注意事项 +
      +
    • 主目录(根目录)禁止上传文件:所有文件必须上传到子目录中,便于分类管理
    • +
    • 单文件大小限制:默认无硬性限制(受服务器磁盘空间和网络带宽影响),建议单文件不超过2GB(大文件推荐分卷压缩后上传)
    • +
    • 批量上传数量:建议单次上传不超过10个文件,避免浏览器卡顿
    • +
    • 网络要求:上传大文件时建议使用有线网络(WiFi可能因信号不稳定导致上传中断)
    • +
    • 文件覆盖规则:若上传的文件名与目标目录中现有文件重名,会直接覆盖原有文件(请谨慎操作)
    • +
    +
  8. +
+
+ +
+

❓ 常见问题解答

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
问题解答
为什么主目录不能上传文件?为了便于媒体文件分类管理,系统设计为仅允许在子目录中上传文件,避免主目录文件杂乱无章,同时降低误操作风险。
支持哪些媒体格式?视频:mp4、avi、mkv、mov等;音频:mp3、wav、flac等;图片:jpg、png、gif等。具体兼容性取决于浏览器,推荐使用mp4/mp3/jpg/png等通用格式。
如何修改媒体根目录?编辑后端主文件(/opt/nas-media-player/main.py)中的VIDEO_ROOT变量,修改为新的目录路径,保存后重启服务即可生效(需确保新目录有读写权限)。
加密目录的密码忘记了怎么办?直接删除该目录下的/opt/nas-media-player/dir_passwords.json文件(隐藏文件),删除后该目录将失去密码保护,可重新设置新密码。
可以在手机/平板上使用吗?可以,系统支持移动端适配,使用手机/平板的现代浏览器访问http://[服务器IP]:8800即可,操作逻辑与电脑端一致。
上传的文件在哪里可以找到?上传的文件会保存在服务器的VIDEO_ROOT配置目录下的对应子目录中,可直接通过文件管理器访问服务器上的该目录查看。
为什么部分视频文件无法播放?可能原因:1. 浏览器不支持该视频编码格式;2. 文件损坏;3. 缺少FFmpeg组件。建议安装FFmpeg或转换视频格式为mp4(H.264编码)。
如何修改服务端口(非8800)?启动服务时修改端口参数,如/opt/nas-media-player/main.py(改为8888端口),同时需确保新端口未被占用且防火墙已开放。
+
+ +
+

🛠️ 故障排除

+ +

1. 无法访问系统(页面无法打开)

+
    +
  • 检查服务是否正常运行: +
      +
    • Linux:sudo systemctl status nas-media-playerps aux | grep uvicorn
    • + +
    +
  • +
  • 确认端口8800是否被占用: +
      +
    • Linux:netstat -tulpn | grep 8800lsof -i:8800
    • + +
    +
  • +
  • 检查防火墙设置: +
      +
    • Linux:确保ufw/iptables已开放8800端口(sudo ufw allow 8800
    • +
    • 路由器:外网访问需在路由器中配置端口映射(8800端口映射到服务器内网IP)
    • +
    +
  • +
  • 确认访问地址是否正确:应为http://[服务器IP]:8800,注意区分http/https(默认使用http)
  • +
+ +

2. 媒体文件无法播放

+
    +
  • 检查文件路径:确认文件存在于配置的媒体目录中,且路径中无中文/特殊字符(部分系统对中文路径支持不佳)
  • +
  • 检查文件权限:需确保文件有读权限(chmod 644 [文件名]
  • +
  • 检查文件完整性:重新上传文件或在本地验证文件是否可正常播放
  • +
  • 查看浏览器控制台错误:按F12打开开发者工具,切换到Console标签,查看具体错误信息(如格式不支持、跨域问题等)
  • +
  • 尝试更换浏览器:部分格式在特定浏览器中兼容性较差,建议使用Chrome测试
  • +
+ +

3. 无法上传文件

+
    +
  • 确认是否选择了子目录:主目录不支持上传,必须选择子目录
  • +
  • 检查目标目录权限:Linux需确保目录有写权限(chmod 755 [目录名]
  • +
  • 检查文件大小:若文件过大(如超过10GB),可能因网络或服务器配置导致上传失败,建议分卷上传
  • +
  • 检查网络连接:上传过程中网络中断会导致失败,可尝试缩短上传文件大小或更换网络
  • +
  • 加密目录上传失败:确认已正确输入密码并通过验证,Cookie未过期
  • +
+ +

4. 加密目录验证失败

+
    +
  • 确认输入的密码是否正确(区分大小写)
  • +
  • 清除浏览器Cookie后重新验证:打开浏览器设置→隐私和安全→清除浏览数据→选择Cookie和其他网站数据,清除后重新访问
  • +
  • 检查/opt/nas-media-player/dir_passwords.json文件:确认加密目录下的dir_passwords.json文件存在且格式正确(由系统自动生成,请勿手动修改)
  • +
  • 验证服务器时间:若服务器时间与客户端时间差异过大(超过1小时),可能导致Cookie验证失败,需同步服务器时间
  • +
+ +

5. 创建目录失败

+
    +
  • 检查目录名称:是否包含特殊字符、长度是否符合要求(2-30字符)
  • +
  • 检查父目录权限:是否有在父目录中创建子目录的权限
  • +
  • 检查目录是否重名:同级目录中是否已存在同名目录
  • +
  • 检查磁盘空间:服务器磁盘是否已满,无剩余空间无法创建新目录
  • +
+
+ +
+

📞 技术支持

+

如遇到使用问题或功能建议,请通过以下方式获取支持:

+
    +
  • 邮箱支持:teasiu@qq.com
  • +
  • 社区论坛:NAS媒体播放器官方论坛(bbs.histb.com)
  • + +
+

+ © 2025 海纳思 版权所有 | 保留所有权利 +

+
+ + +