文章

树莓派家庭影院(二):点播、播放、密码管理与统一入口的服务整合

在现有 Radarr 下载管线上接入 Jellyfin、Jellyseerr(Seerr)、Sonarr、Vaultwarden 与 Homepage,形成从点播、下载、播放到密码管理和统一入口的完整树莓派 homelab。

树莓派家庭影院(二):点播、播放、密码管理与统一入口的服务整合

本系列共三篇第一篇搭建“搜索 → 下载 → 字幕”的自动化电影下载管线;第二篇(本文)接入点播、播放、剧集、密码管理和统一入口;第三篇让电视上的 Kodi 使用 Jellyfin 媒体库与服务端字幕。

  1. 1. 现有下载管线与四个补全目标
  2. 2. 补全家庭影院的点播与播放链
    1. 2.1 Jellyfin 建立共享媒体库
    2. 2.2 部署 Jellyfin
      1. LinuxServer.io 镜像约定
    3. 2.3 Jellyseerr 建立点播入口
    4. 2.4 从点播到播放的完整路径
    5. 2.5 Sonarr 补全剧集管线
    6. 2.6 补全后的完整架构
    7. 2.7 端到端时序:从打开首页到按下播放
  3. 3. 使用 Vaultwarden 统一管理凭据
    1. 3.1 密码管理的目标
    2. 3.2 一个服务端,多种客户端
    3. 3.3 部署服务端并创建密码库账号
    4. 3.4 连接并使用 Chrome 扩展
    5. 3.5 零知识加密的数据流程
      1. 注册:建立账号和加密材料
      2. 登录:在设备上取得并解密保险库
      3. 锁定、解锁与同步
    6. 3.6 安全边界与日常规则
  4. 4. 使用 Homepage 与 Caddy 统一服务入口
    1. 4.1 mDNS 提供零配置主机名
    2. 4.2 裸端口与 Caddy 子路径的边界
    3. 4.3 Homepage 承担唯一导航入口
    4. 4.4 最终入口配置
    5. 4.5 三种入口模型对比
  5. 5. 升级 Seerr 与排障实录
    1. 5.1 Jellyseerr 更名 Seerr
    2. 5.2 Seerr 与 *arr 联动的三个坑
    3. 5.3 剧集“全季请求”的体积教训
    4. 5.4 mDNS 双网卡导致主机名“时好时坏”
    5. 5.5 删除剧集不会撤回下载任务
    6. 5.6 路径统一:拆掉所有翻译层
    7. 5.7 画质天梯、死种与做种数下限
    8. 5.8 剧集字幕断链:Bazarr 的 Sonarr 残次配置
    9. 5.9 播放端转码:FFmpeg 为什么拉满 CPU
  6. 6. 当前架构与扩展方向
    1. 6.1 日常使用与维护重点
    2. 6.2 可继续补充的服务

1. 现有下载管线与四个补全目标

树莓派上原本已经运行着一条自动化下载管线:Radarr、Jackett、qBittorrent、Bazarr 和 ChineseSubFinder 分别负责电影管理、资源索引、下载和字幕,统一的 /share 挂载还保证了下载目录到媒体库之间可以使用硬链接而不是复制

这套系统已经能自动完成“搜索资源 → 下载 → 整理入库”,但家庭成员真正使用它时还缺少四个面向人的能力:

能力现有缺口补充组件
发现与点播必须直接操作 Radarr 搜索电影、选择资源Jellyseerr
媒体库与播放Samba 只提供文件,缺少跨设备海报墙和观看进度Jellyfin
凭据管理多个 WebUI 密码和 API Key 分散在文档中Vaultwarden
服务入口每个 WebUI 使用不同端口,地址难以记忆Homepage + Caddy

四个组件并不替换原来的下载管线,而是分别接在它的上游、下游和管理面:

flowchart LR
    Family[家人] -->|搜片点想看| JS[Jellyseerr 5055]
    JS -->|电影任务| Radarr[Radarr 7878]
    JS -->|剧集任务| Sonarr[Sonarr 8989]
    Radarr --> Jackett[Jackett 9117]
    Sonarr --> Jackett
    Radarr --> qBT[qBittorrent 8085]
    Sonarr --> qBT
    qBT --> Downloads[(/share/Downloads)]
    Radarr -->|硬链接入库| Movies[(/share/Movies)]
    Sonarr -->|硬链接入库| Series[(/share/Video/Series)]
    Movies --> JF[Jellyfin 8096]
    Series --> JF
    JF -->|海报墙/播放| Family
    Family -->|从一个地址进入| HP[Homepage 3001]
    Family -->|自动填充凭据| VW[Vaultwarden 8443]
    style JS fill:#e3f2fd
    style JF fill:#e8f5e9
    style HP fill:#fff3bf
    style VW fill:#fff3bf

最终形成的使用路径是:家人在 Jellyseerr 发起点播,下载管线自动完成资源入库,Jellyfin 提供统一媒体库;Homepage 汇总所有管理入口,Vaultwarden 则为这些服务保存并填写独立密码。四个服务的部署配置均已提交到 pi-docker-homelab 仓库。

2. 补全家庭影院的点播与播放链

家庭影院的完整链路包含“提出观看需求”和“消费媒体内容”两个面向用户的阶段。Jellyseerr 接在 Radarr 上游,负责点播;Jellyfin 接在电影目录下游,负责媒体库和播放。两者之间继续复用原有下载管线。

2.1 Jellyfin 建立共享媒体库

原来的播放路径是“Radarr 入库 → Samba 共享电影文件 → 电视上的 Kodi 扫描并播放”。这条路径适合单台电视,但媒体库元数据和观看状态都保存在 Kodi 本机,手机、电脑和另一台电视无法直接共享。

Jellyfin 把媒体库提升为独立的服务端能力:

维度Kodi + SambaJellyfin
片库来源Kodi 直接扫描共享文件Jellyfin 服务端扫描 /share/Movies
海报与简介保存在单台 Kodi 设备服务端统一刮削并提供给所有客户端
观看进度只属于当前设备按用户跨设备同步
客户端接入每台设备配置 Samba 和 Kodi浏览器、手机 App、电视客户端直接登录

Jellyfin 负责媒体库,并不要求放弃 Kodi。通过官方的 Jellyfin for Kodi 插件,电视仍可使用 Kodi 的播放器和解码能力,片库、海报和观看进度则统一交给 Jellyfin 管理。这样既保留电视端直接播放的稳定性,也获得多用户和跨设备续播能力。

字幕也遵循同一条服务端链路:Bazarr 接收 Radarr、Sonarr 的媒体事件,ChineseSubFinder 定时扫描电影和剧集,两者把原版外挂字幕写到视频旁边,再由 Jellyfin 统一读取。字幕源、语言优先级和文件落盘规则集中放在第一篇的字幕完整链路,本篇只负责把 Jellyfin 接到这条管线下游。

2.2 部署 Jellyfin

Jellyfin 使用 linuxserver/jellyfin 镜像,与现有 homelab 的权限和目录约定保持一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  jellyfin:
    image: linuxserver/jellyfin:latest
    container_name: jellyfin
    environment:
      - PUID=1000
      - PGID=100
      - TZ=Asia/Shanghai
      # 刮削元数据需要访问 TMDB,走宿主机 V2Ray 代理
      - HTTP_PROXY=http://172.18.0.1:10809
      - HTTPS_PROXY=http://172.18.0.1:10809
      - NO_PROXY=localhost,127.0.0.1,jellyseerr,radarr,sonarr
    volumes:
      - /home/pi/docker/jellyfin/config:/config
      - /home/pi/docker/jellyfin/cache:/cache
      # 只读挂载片库即可,元数据写在 config 里
      - /share/Movies:/data/movies:ro
    ports:
      - "8096:8096"
    restart: unless-stopped

配置中有三个关键点:

  • 片库只读挂载:ro),Jellyfin 的元数据、字幕缓存都写在自己的 config/cache 里,不会污染片库目录;
  • 容器内媒体库路径统一为 /data/movies,与宿主机目录解耦;
  • 刮削需要访问 TMDB,和 Jackett 一样走宿主机 V2Ray 代理,否则海报和简介无法加载。

首次访问 http://raspberrypi.local:8096 创建管理员,添加媒体库时选容器内路径 /data/movies。有两个设置值得立刻确认:媒体库的实时监控要开启,否则新入库的内容要等约 12 小时的定时扫描才出现(曾因此误以为剧集刮削失败);刮削失败先查代理是否可用,TMDB 不通时 Jellyfin 只会静默跳过元数据。性能上树莓派 1080p 直接播放毫无压力,但不要指望它做 4K 实时转码——客户端支持直接播放才是关键(见 5.9)。

LinuxServer.io 镜像约定

LinuxServer.io 是第三方 Docker 镜像维护团队,不是 Jellyfin、Radarr 等上游应用的开发者。它的价值是用相同规则重新打包一批 homelab 应用:PUID/PGID 统一宿主机文件权限,配置通常挂到 /config,并为 ARM64 等架构提供一致的镜像入口。

维度官方镜像LinuxServer.io 镜像
维护者应用作者或官方组织第三方社区团队
权限模型各项目自行定义通常统一使用 PUID / PGID
配置目录随项目变化通常统一为 /config
运行封装直接运行应用或自定义入口使用 s6-overlay 管理启动流程

统一封装降低了多个容器共同维护时的配置成本,也多引入了一层依赖。基础镜像升级可能改变启动钩子等约定,例如 custom-cont-init.d 的位置变化就与 LinuxServer.io 基础镜像有关,而不是 Radarr 本身。这里选择它是为了与现有媒体栈保持一致;核心数据库或基础设施仍优先使用官方镜像。

2.3 Jellyseerr 建立点播入口

Jellyseerr 位于家庭成员和 Radarr 之间。家人只需要搜索电影并提交请求,Jellyseerr 负责把请求转换为 Radarr 任务;Radarr、索引器和下载器的实现细节不会暴露给普通用户。

1
2
3
4
5
6
7
8
9
10
11
12
13
  jellyseerr:
    # 已更名 Seerr,现用镜像为 ghcr.io/seerr-team/seerr:latest,见 5.1 节
    image: fallenbagel/jellyseerr:latest
    container_name: jellyseerr
    environment:
      - TZ=Asia/Shanghai
      # 不要在这里配 HTTP_PROXY:env 代理会劫持包括 Jellyfin/Radarr 在内的全部
      # 请求且 NO_PROXY 不生效,TMDB 代理改在 Settings → Network 里配置
    volumes:
      - /home/pi/docker/jellyseerr/config:/app/config
    ports:
      - "5055:5055"
    restart: unless-stopped

首次访问 http://raspberrypi.local:5055 时选择 Jellyfin 登录并使用 Jellyfin 管理员账号完成初始化,后续普通家庭成员直接用各自的 Jellyfin 账号登录即可。初始化后还有两项关键设置:

  • Settings → Network:搜索和海报依赖 TMDB API,在这里把代理指向同一 Docker 网络里的 V2Ray 容器(Hostname 填 v2ray、端口 10809),并用 bypass 规则排除 jellyfinradarr 和本地地址。这个代理只作用于外发请求,是 Jellyseerr 配代理的正确位置。
  • Settings → Services → Radarr:Host 填 Compose 服务名 radarr,端口填 7878,API Key 从 Radarr 设置页复制;Test 通过后必须选好 Quality Profile 和 Root Folder(Radarr 容器内的电影根目录),并设为 Default Server,否则点播请求会静默失败。

这里有一个容易踩的坑:不要给 Jellyseerr 容器配置 HTTP_PROXY 环境变量。环境变量代理会劫持进程内包括 Jellyfin、Radarr 在内的全部 HTTP 请求,而 Jellyseerr 不尊重 NO_PROXY——内部请求被送进 V2Ray 后无法解析容器主机名,登录和联动会全部失败。Jackett 等应用可以用 env 代理,是因为它们尊重 NO_PROXY,不能把这个经验套到 Jellyseerr 上。

2.4 从点播到播放的完整路径

媒体链路至此形成四个连续阶段:

  1. 家人在 Jellyseerr 搜索电影并提交请求。
  2. Jellyseerr 把请求发送给 Radarr,Radarr 通过 Jackett 选择资源并交给 qBittorrent 下载。
  3. Radarr 使用硬链接把完成的文件整理到 /share/Movies,Jellyfin 扫描到新电影并补充元数据。
  4. 家人从 Jellyfin 的任意客户端播放,观看进度回写服务端并在其他设备同步。

下载组件继续处理自动化细节,Jellyseerr 和 Jellyfin 分别提供面向用户的入口与出口。

2.5 Sonarr 补全剧集管线

Radarr 的数据模型里只有电影,它的搜索只接 TMDB 的电影接口,剧集在它面前根本不存在。剧集对应的是它的姊妹项目 Sonarr:管理“剧 → 季 → 集”三层结构,支持按季监控、追更新集、抓取 season pack。点播入口按媒体类型分流:movie 请求发给默认 Radarr,tv 请求发给默认 Sonarr,缺哪一边,哪种类型的请求就会在“已批准”状态下原地不动。这条分工是 *arr 系列本身的边界,不是 Jellyseerr 的偏好。

Sonarr 的部署沿用 Radarr 的全部约定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  sonarr:
    image: linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=1000
      - PGID=100
      - TZ=Asia/Shanghai
      - HTTP_PROXY=http://172.18.0.1:10809
      - HTTPS_PROXY=http://172.18.0.1:10809
      - NO_PROXY=localhost,127.0.0.1,qbittorrent,radarr,sonarr,jackett,bazarr,jellyseerr
    volumes:
      - /home/pi/docker/sonarr/config:/config
      # 与 radarr 相同的单一 /share 挂载,保证导入时硬链接而非复制
      - /share:/share
    ports:
      - "8989:8989"
    restart: unless-stopped

接线时与 Radarr 不同的几点:

  • 根目录用剧集库 /share/Video/Series;该目录如果是从别处拷贝来的,注意属主(本机曾是 nobody:nogroup),否则 Sonarr 会报目录不可写。
  • 索引器同样从 Jackett 接入 Torznab,但分类要选剧集类(5000 系列),直接照抄 Radarr 的电影分类(2000 系列)会什么都搜不到。
  • qBittorrent 下载器配置与 Radarr 相同,category 单独用 tv-sonarr,避免和电影下载混在一起。
  • Seerr 的代理 bypassFilter 要补上 sonarr(见 2.3 的代理说明),否则 Seerr 到 Sonarr 的内部请求会被送进 V2Ray 而失败。
  • Bazarr 的 Webhook 通知也要照 Radarr 配一份:Sonarr 的 Settings → Connect 添加 Webhook,URL 把第一篇的 /api/webhooks/radarr 换成 /api/webhooks/sonarrapikey 仍是 Bazarr 的),勾选的事件与 Radarr 侧相同。漏配不会断功能——Bazarr 每 60 分钟轮询兜底——但新入库剧集的字幕会多等一个轮询周期。
  • Jellyfin 侧同步新增一个“剧集”媒体库:compose 里加只读挂载 /share/Video/Series:/data/series:ro,再在媒体库设置里添加即可。

之后在 Seerr 的 Settings → Services → Sonarr 中关联:Host 填 sonarr、端口 8989、API Key 从 Sonarr 设置页复制,Test 通过后选好 Quality Profile 和根目录并设为 Default Server。剧集点播随后走与电影完全相同的自动化路径,只是终点目录换成 /share/Video/Series

剧集链路还有两个容易遗忘的下游:Bazarr 需要单独接入 Sonarr 才会处理剧集字幕(漏接的真实事故见 5.8);Jellyfin 需要为 /share/Video/Series 单独建“剧集”媒体库,且容器里要有对应的只读挂载。

2.6 补全后的完整架构

第一篇第 2 节给出的“最终架构”只包含电影下载管线;第 1 节的示意图也只画了新增的四个组件。把两部分合并,补全后的完整架构如下:

flowchart TB
    Family[家人 / 各设备播放端]

    subgraph PI[树莓派]
        subgraph ENTRY[统一入口]
            Caddy[Caddy 80/443]
            HP[Homepage 3001]
        end
        subgraph UFACE[点播与播放]
            SE[Seerr 5055]
            JF[Jellyfin 8096]
        end
        subgraph ARR[下载管线]
            R[Radarr 7878 电影]
            SO[Sonarr 8989 剧集]
            J[Jackett 9117]
            Q[qBittorrent 8085]
            B[Bazarr 6767]
            CSF[ChineseSubFinder 19035]
        end
        subgraph STORE["/share 存储"]
            DL[(/share/Downloads)]
            MV[(/share/Movies)]
            SR[(/share/Video/Series)]
        end
        subgraph SUP[基础支撑]
            VW[Vaultwarden 8443]
            AG[AdGuard Home 53]
            V2[V2Ray 代理出口]
        end
    end

    IDX[BT 站点:TPB / TheRARBG]
    SUBS[字幕站:OpenSubtitles / 字幕库]
    META[TMDB / TVDB 元数据]

    Family -->|唯一入口 raspberrypi.local| Caddy
    Caddy --> HP
    Caddy -->|HTTPS 反代| VW
    HP -->|卡片直达各服务| SE
    Family -->|搜片点播| SE
    SE <-->|登录认证| JF
    SE -->|movie 请求| R
    SE -->|tv 请求| SO
    R -->|Torznab| J
    SO -->|Torznab| J
    J --> IDX
    R -->|下发任务| Q
    SO -->|下发任务| Q
    Q --> DL
    R -->|硬链接入库| MV
    SO -->|硬链接入库| SR
    B <-->|同步媒体库| R
    B <-->|同步媒体库| SO
    B --> SUBS
    B -->|写入原版中文/英文字幕| MV
    B -->|写入原版中文/英文字幕| SR
    CSF -->|扫描补字幕| MV
    MV -->|只读挂载| JF
    SR -->|只读挂载| JF
    JF -->|海报墙 / 播放 / 进度同步| Family
    SE -.->|元数据查询走代理| V2
    R -.->|元数据查询走代理| V2
    SO -.->|元数据查询走代理| V2
    V2 -.-> META
    Family -.->|局域网 DNS| AG

架构按职责分四层:统一入口(Caddy 反代 Homepage 和 Vaultwarden,见第 4 节)承接所有访问;点播与播放是家人直接面对的进出两端;下载管线沿用第一篇的自动化链路,Sonarr 接入后电影和剧集共用 Jackett 与 qBittorrent;基础支撑提供凭据、DNS 和代理出口。所有组件围绕 /share 存储交换数据,路径全链路统一(见 5.6)。

2.7 端到端时序:从打开首页到按下播放

第一篇 2.2 的时序图只覆盖电影下载管线;接入点播入口、剧集管线和播放端之后,完整链路如下:

sequenceDiagram
    actor U as 家人
    participant H as Homepage
    participant SE as Seerr
    participant JF as Jellyfin
    participant R as Radarr
    participant SO as Sonarr
    participant J as Jackett
    participant Q as qBittorrent
    participant B as Bazarr
    participant S as /share 媒体库

    U->>H: 打开 raspberrypi.local,点 Jellyseerr 卡片
    U->>SE: 用 Jellyfin 账号登录
    SE->>JF: 校验用户名密码
    JF-->>SE: 认证通过
    U->>SE: 搜索影片并提交请求
    alt 电影请求
        SE->>R: 下发 movie 请求
    else 剧集请求
        SE->>SO: 下发 tv 请求(可按季勾选)
    end
    Note over R,SO: 以下流程两者相同,以 Radarr 为例
    R->>J: Torznab 查询种子
    J-->>R: 返回候选列表
    R->>R: 按 1080-4k profile 选最优版本
    R->>Q: 添加下载任务(电影 category=radarr,剧集 category=tv-sonarr)
    Q->>Q: P2P 下载
    Q-->>R: 下载完成
    R->>S: 硬链接导入(电影入 /share/Movies,剧集入 /share/Video/Series)
    R-->>B: Webhook 通知下载/移动完成(Bazarr 同时轮询兜底)
    B->>B: 按 zh 优先、en 兜底下载原版字幕
    B->>S: 原样写入字幕文件
    JF->>S: 扫描新文件,补充海报与元数据
    U->>JF: 任意客户端播放,观看进度多端同步

与第一篇时序图相比,链路的三个变化:

  1. 入口前移:用户不再直接面对 Radarr,而是从 Homepage 进 Seerr 点播,登录直接复用 Jellyfin 账号(Seerr 调 Jellyfin 认证接口校验),家人不需要知道 *arr 的存在;
  2. 中段分流:Seerr 按媒体类型把请求分发给 Radarr 或 Sonarr(见 2.5 的分工说明),分流之后的“查询 → 下载 → 导入 → 字幕”流程两者完全一致,只是下载分类和终点目录不同;
  3. 出口补齐:导入媒体库只是终点的一半,Jellyfin 扫描入库并面向所有播放端提供播放和进度同步,链路才真正闭环。

3. 使用 Vaultwarden 统一管理凭据

凭据管理由一个自托管服务端和多个客户端共同完成:Vaultwarden 在树莓派上同步加密数据,Bitwarden 浏览器扩展和 App 在用户设备上解密、生成并填写密码。理解这组分工后,账号归属、插件登录方式和加密流程就可以沿着同一条数据链展开。

3.1 密码管理的目标

homelab 里的服务越来越多,Jellyfin、Radarr、qBittorrent 和 Portainer 都有自己的登录凭据。把这些密码写进部署文档虽然方便,却会同时带来明文泄露和密码复用问题;一旦文档被公开发布,密码也会随文章进入互联网——第一篇就是把所有凭证直接写进了正文,发布即视为泄露,这一节要的正是终结这种管理方式。

服务只监听局域网,能阻止互联网设备直接访问,却不能防住访客网络、中毒设备或已经进入内网的人。更稳妥的管理方式是:

  • 人只记住一个足够强的主密码
  • 每个服务使用不同的随机强密码;
  • 密码以密文形式集中保存,并在电脑和手机之间同步;
  • 登录服务时由客户端自动填充,不再从文档复制明文。

Vaultwarden 用来实现密文存储与同步,Bitwarden 客户端负责生成、解密和填写密码。

3.2 一个服务端,多种客户端

Vaultwarden 是兼容 Bitwarden 协议的轻量级自托管服务端。它运行在树莓派上,保存账号信息和加密后的保险库。浏览器里的 Web 保险库、Chrome 扩展、手机 App 和桌面 App 则是 Bitwarden 客户端,所有加解密都发生在这些客户端上。

四者之间是同一套账号和数据:先在树莓派的 Vaultwarden 网页注册一个密码库账号,再用同一邮箱和主密码登录各个客户端。Radarr、Jellyfin 等服务的账号不是用来登录 Bitwarden 的账号,而是登录 Bitwarden 后保存在保险库里的密码条目。

flowchart LR
    VW[(Vaultwarden 服务端<br>存储与同步密文)]
    Web[Web 保险库]
    Ext[Chrome 扩展]
    App[手机 / 桌面 App]
    Svc[Radarr / Jellyfin 等服务]

    VW <-->|同一个 Vaultwarden 账号<br>同步加密保险库| Web
    VW <-->|同一个 Vaultwarden 账号<br>同步加密保险库| Ext
    VW <-->|同一个 Vaultwarden 账号<br>同步加密保险库| App
    Ext -->|自动填充服务账号| Svc
    App -->|系统级自动填充| Svc
    style VW fill:#fff3bf
    style Svc fill:#e8f5e9

Bitwarden 扩展默认连接官方的 bitwarden.com。官方服务器和树莓派上的 Vaultwarden 是两个独立的账号空间,即使使用相同邮箱,数据也不会自动互通;连接自托管服务前必须先把客户端的服务器地址切换到 Vaultwarden。

3.3 部署服务端并创建密码库账号

Vaultwarden 使用 Rust 编写,资源占用很低,适合直接放进树莓派现有的 Docker Compose:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    environment:
      - TZ=Asia/Shanghai
      # 初次使用先开放注册,建好账号后改为 false 重建容器
      - SIGNUPS_ALLOWED=true
      - HTTP_PROXY=http://172.18.0.1:10809
      - HTTPS_PROXY=http://172.18.0.1:10809
      - NO_PROXY=localhost,127.0.0.1
    volumes:
      # 保险库数据,务必纳入备份
      - /home/pi/docker/vaultwarden/data:/data
    ports:
      - "8001:80"
    restart: unless-stopped

Vaultwarden 的 Web 保险库依赖浏览器 Web Crypto API(SubtleCrypto)在本地完成加解密。浏览器只在 HTTPS 或 localhost 这类安全上下文中开放该 API,因此通过 http://raspberrypi.local:8001 直接访问会报 “You are not using a secure context … You need to enable HTTPS!”。

Vaultwarden 不支持子路径,这里让 Caddy 为它单独提供一个 HTTPS 端口:

raspberrypi.local:8443 {
    reverse_proxy vaultwarden:80
}

服务端和账户按下面的顺序初始化:

  1. 保持 SIGNUPS_ALLOWED=true 启动容器,暂时开放注册入口。
  2. 让电脑信任 Caddy 的本地根证书,确认浏览器打开 https://raspberrypi.local:8443 时没有证书警告。
  3. 在 Web 保险库注册邮箱和主密码。这里创建的就是整个密码库账号,后续所有 Bitwarden 客户端都使用它登录。
  4. 登录 Web 保险库确认账号可用,再把 SIGNUPS_ALLOWED 改为 false 并重建容器,关闭新用户注册。

如果网页能打开而客户端提示网络或证书错误,需要先把 Caddy 根证书加入客户端设备的系统信任库。仅在浏览器警告页点击“继续访问”,不一定能让扩展和手机 App 同时信任该证书。

3.4 连接并使用 Chrome 扩展

按照 Bitwarden 官方的自托管客户端连接方式,Chrome 扩展首次登录前先指定服务器:

  1. 打开扩展,在登录页找到 Logging in on(中文界面可能显示“登录到”或“服务器”)。
  2. 选择 Self-hosted,在 Server URL 填入 https://raspberrypi.local:8443 并保存。
  3. 使用 3.3 节在 Web 保险库注册的邮箱和主密码登录。

服务器地址只填写 HTTPS 根地址,不附加 /admin,也不填写容器内部地址或 HTTP 端口。配置完成后,扩展会从树莓派下载这个账号的加密保险库。

以 Radarr 为例,新建一条“登录”记录,依次填写名称、用户名、密码和登录网址(URI)。其中 URI 用来建立密码条目与网站之间的对应关系;再次打开 Radarr 登录页时,扩展会自动列出匹配记录。创建新账号或修改密码时,可以直接使用扩展生成随机强密码,并把最终生效的密码保存回条目。

自动填充的完整过程如下:

sequenceDiagram
    participant S as Vaultwarden 服务端
    participant BW as Bitwarden 扩展
    participant B as Chrome
    participant R as Radarr

    S-->>BW: 登录或同步时下载加密条目
    BW->>BW: 本地解锁,并按当前 URI 匹配 Radarr 条目
    BW->>B: 把用户名和密码填入登录表单
    B->>R: 正常提交表单
    Note over BW,R: 提交过程与手动输入完全相同

Vaultwarden 负责把密文同步给扩展;扩展在本机解密并填入浏览器;浏览器再向 Radarr 提交普通登录请求。没有安装扩展时,也可以从 Web 保险库或 App 复制密码后手动粘贴。手机上的对应能力是 Android Autofill 或 iOS 密码自动填充。

Bitwarden 客户端还区分登录解锁两种状态。登录发生在新设备或退出后的设备上,需要连接 Vaultwarden 完成身份认证并下载加密保险库。锁定则保留账号和本地加密缓存,只清除内存中的明文与解密密钥;之后使用主密码、PIN 或生物识别解锁时,可以直接读取本地缓存,不要求树莓派在线。日常使用保持“已登录但锁定”即可,只有更换账号或清除本地数据时才需要退出登录。Bitwarden 官方文档也将登录与解锁定义为两个独立过程。

3.5 零知识加密的数据流程

Vaultwarden 采用 Bitwarden 的零知识加密模型:服务器保存账号元数据、认证材料和加密后的保险库,但不接收主密码、未加密的账号加密密钥或明文密码。加密和解密都在 Web 保险库、浏览器扩展或 App 内完成。

按照 Bitwarden 的安全白皮书,最常见的“邮箱 + 主密码”流程包含下面几类密钥:

  • 主密码是人需要记住的秘密,只作为客户端计算的输入,从不上传服务器。
  • 主密钥(Master Key)由客户端使用邮箱、主密码和 KDF 参数派生。PBKDF2-SHA256 默认执行 60 万轮,也可以改用 Argon2id;高计算成本用于减慢暴力猜测。
  • 拉伸主密钥(Stretched Master Key)由主密钥经过 HKDF 扩展,用来保护账号加密密钥。
  • 账号加密密钥(Account Encryption Key / User Symmetric Key)在注册时随机生成,是保护整个个人保险库的长期“总钥匙”。服务器只保存它被加密后的版本。
  • 条目密钥(Cipher Key)为每条登录、卡片或安全笔记单独生成,用来加密条目正文;账号加密密钥再保护这些条目密钥。
  • 主密码哈希(Master Password Hash)从主密钥继续派生,只用于服务器认证,不参与保险库解密。

注册:建立账号和加密材料

注册只发生一次,用来创建账号、随机密钥和服务器端记录:

sequenceDiagram
    participant U as 用户
    participant C as Bitwarden 客户端
    participant S as Vaultwarden 服务端

    U->>C: ① 输入邮箱与主密码
    C->>C: ② KDF 派生主密钥,再用 HKDF 拉伸
    C->>C: ③ 随机生成账号加密密钥并加密保护
    C->>C: ④ 另行派生主密码哈希
    C->>S: ⑤ 上传邮箱、主密码哈希和受保护的账号加密密钥

第 ① 步提供账号标识和只有用户知道的秘密。第 ② 步把人类可记忆的密码转换成适合密码学运算的密钥材料,并用大量计算提高离线猜测成本。第 ③ 步生成真正负责长期保护保险库的随机密钥;主密码以后发生变化时,通常只需更换这把密钥的外层保护,不必重写所有条目正文。第 ④ 步产生独立的认证凭据,把“向服务器证明身份”和“在本地解密数据”分成两条路径。第 ⑤ 步把账号记录交给服务器保存,此时服务器拿到的账号加密密钥已经处于加密状态。

保存 Radarr 等条目时,客户端会先用各自的 Cipher Key 加密正文,再用账号加密密钥保护 Cipher Key,最后只把密文上传到 Vaultwarden。

登录:在设备上取得并解密保险库

新设备首次登录,或者设备退出后重新登录,需要同时完成服务器认证和本地解密:

sequenceDiagram
    participant U as 用户
    participant C as Bitwarden 客户端
    participant S as Vaultwarden 服务端

    U->>C: ① 输入邮箱与主密码
    C->>C: ② 重算主密钥、拉伸主密钥和主密码哈希
    C->>S: ③ 发送邮箱与主密码哈希请求认证
    S-->>C: ④ 返回受保护的账号加密密钥与加密保险库
    C->>C: ⑤ 解开账号加密密钥和条目密钥,在内存中得到明文

第 ①、② 步在新设备上重新生成相同的密钥材料,避免在设备之间传输明文钥匙。第 ③ 步只把认证用哈希发送给 Vaultwarden;如果启用了 2FA,也在这个阶段校验。第 ④ 步由服务器返回同步所需的密文。第 ⑤ 步完全在客户端内完成,先解开账号加密密钥,再解开每个 Cipher Key,最后得到可查看、复制和自动填充的条目内容。

锁定、解锁与同步

登录成功后,客户端会把加密保险库缓存在本地。锁定、解锁和退出登录对应三种不同状态:

stateDiagram-v2
    [*] --> 已退出
    已退出 --> 已登录且解锁: 连接服务器认证、下载并本地解密
    已登录且解锁 --> 已登录但锁定: 清除内存中的明文与解密密钥
    已登录但锁定 --> 已登录且解锁: 在本机恢复密钥并解密缓存
    已登录且解锁 --> 已退出: 清除账号状态与本地缓存

解锁只在已经登录的设备上发生,因此可以离线完成。主密码、PIN 或生物识别在本机恢复账号加密密钥,随后解开已有缓存。同步则需要连接 Vaultwarden:客户端把新增或修改后的条目先在本地加密,再上传密文;其他客户端连回服务器后下载这些密文并在各自设备上解开。

3.6 安全边界与日常规则

这套结构把密码的存储、同步和填写统一起来,同时留下几条需要单独处理的安全边界:

  • 主密码必须强且不能遗忘:默认个人账号没有可供服务器取回的明文主密码或解密密钥;主密码过弱还会让数据库泄露后的离线暴力猜测变得可行。
  • 服务密码全部随机且互不相同:密码记忆和填写已经由客户端接管,没有继续复用弱密码的必要。
  • 服务器数据必须备份/home/pi/docker/vaultwarden/data 是保险库的服务端主副本,客户端缓存不能替代正式备份。这份目录应定期复制到另一块存储介质。
  • 自动填充不负责目标服务的传输安全:扩展把密码填进表单后,提交过程与手动输入相同。Radarr 等服务如果仍使用 HTTP,凭据在局域网传输时仍缺少 HTTPS 保护。
  • 真实密码和 API Key 不再写入文档:密码管理器可以替换存储工具,但不能替代发布前的内容检查。

4. 使用 Homepage 与 Caddy 统一服务入口

随着 WebUI 数量增加,入口设计需要同时满足四个条件:日常只记一个地址、局域网设备无须单独配置 DNS、不能要求每个后端支持子路径、Vaultwarden 仍能获得 HTTPS。先明确这些约束,再选择反向代理和导航页的职责,会比给每个服务机械地套一层代理更简单。

4.1 mDNS 提供零配置主机名

树莓派上的 Avahi 通过 mDNS 广播 raspberrypi.local。同一局域网中的电脑和手机可以直接解析这个主机名,不需要把 DNS 指向 AdGuard Home,也不依赖路由器下发自定义记录。

零配置不代表零坑:机器同时接着多张网卡时,Avahi 默认把所有接口的地址都发布出去,客户端解析到不可达的地址就会表现为服务“时好时坏”,排查与收敛方法见 5.4

mDNS 只负责主机名,不会自动解析 radarr.raspberrypi.localjellyfin.raspberrypi.local 等多级子域名。为每个服务分配子域名意味着额外维护局域网 DNS;全部保留在 raspberrypi.local 下则可以继续使用 mDNS 的零配置能力。

Caddy 用来承接这个主机名。与 Nginx 相比,它在当前场景中的优势不是代理能力更强,而是配置短,并能为 .local 主机名签发本地证书、处理 HTTPS 跳转。局域网入口因此只需要一个 Caddyfile 和客户端对本地根证书的信任。

4.2 裸端口与 Caddy 子路径的边界

没有统一入口时,每个服务都需要单独记住端口:

服务端口服务端口
Radarr7878Sonarr8989
Jackett9117Bazarr6767
qBittorrent8085ChineseSubFinder19035
Jellyfin8096Jellyseerr5055
AdGuard Home8080  

Caddy 子路径可以把这些地址改写为 raspberrypi.local/radarrraspberrypi.local/jackett 等形式:

raspberrypi.local {
    reverse_proxy /radarr* radarr:7878
    reverse_proxy /jackett* jackett:9117
    reverse_proxy /bazarr* bazarr:6767
    reverse_proxy /adguard* adguardhome:80
}

子路径代理要求后端知道自己的 URL Base。Radarr、Jackett 和 Bazarr 可以分别配置 UrlBaseBasePathOverridebase_url,让静态资源与 API 请求都携带路径前缀;不支持 URL Base 的应用仍会请求根路径 /,导致资源 404 或页面白屏。

qBittorrent WebUI 的 API 路径以 /api/v2/... 为根,ChineseSubFinder 也按根路径部署,两者无法完整接入这套子路径。继续为部分服务保留裸端口后,用户仍要记忆两种地址模型,子路径没有真正统一入口。

4.3 Homepage 承担唯一导航入口

Homepage 把所有服务链接集中到一个导航页,还可以通过只读 Docker socket 展示容器状态。它只负责“从一个入口找到所有服务”,不需要代理或改写后端请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    environment:
      - PUID=1000
      - PGID=100
      - TZ=Asia/Shanghai
      # v1+ 必须显式允许访问的 Host
      - HOMEPAGE_ALLOWED_HOSTS=raspberrypi.local,192.168.1.7:3001,raspberrypi.local:3001
    volumes:
      - /home/pi/docker/homepage/config:/app/config
      # 只读挂 docker socket,用于首页显示容器运行状态
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - "3001:3000"
    restart: unless-stopped

部署时需要处理两个运行条件:ghcr.io 无法直连时,可通过南京大学 GHCR 镜像站拉取后重新 docker tag 为原镜像名;Homepage v1 还要求 HOMEPAGE_ALLOWED_HOSTS 明确列出所有访问用 Host,否则页面虽然能打开,数据接口会返回 Host validation failed

Homepage 本身是 Next.js 应用,长期缺少稳定的子路径支持。早期讨论中的 base 设置需要代理端配合剥离前缀,JS 资源、/api 和 widget 请求仍可能失效;后续讨论也建议直接把根路径交给 dashboard。导航页又是整个系统的第一跳,因此将它放在根路径比 /homepage 更稳定。

4.4 最终入口配置

最终职责划分为:Caddy 只代理 Homepage 和必须使用 HTTPS 的 Vaultwarden;Homepage 卡片直接链接其他服务的原始端口。

raspberrypi.local {
    # 根路径直接给 Homepage 导航页,其他服务从导航页点过去、直连端口
    reverse_proxy homepage:3000
}

# Vaultwarden 的客户端连接依赖 HTTPS(见 3.3),单独保留 HTTPS 端口
raspberrypi.local:8443 {
    reverse_proxy vaultwarden:80
}

Homepage 的服务清单里直接写端口链接(mDNS 下主机名比 IP 好写):

1
2
3
4
5
6
7
- 影音:
    - Jellyfin:
        href: http://raspberrypi.local:8096
        description: 媒体播放
    - Jellyseerr:
        href: http://raspberrypi.local:5055
        description: 点播入口

从子路径方案切换回来时,还需要清理后端的路径配置:Radarr 的 UrlBase 清空,Jackett 的 BasePathOverride 改回 null,Bazarr 的 base_url 改回 /。否则用户从 Homepage 直连端口后,应用仍会跳转到旧的 /radarr 等路径。HOMEPAGE_ALLOWED_HOSTS 则要保留不带端口的 raspberrypi.local,因为经 Caddy 443 访问时 Host 不包含 Homepage 的 3001 端口。

flowchart LR
    User[浏览器 / 手机] -->|mDNS 自动发现| Pi[raspberrypi.local]
    Pi --> Caddy[Caddy 443/8443]
    Caddy -->|根路径 443| HP[Homepage 3001]
    Caddy -->|8443 HTTPS| VW[Vaultwarden]
    HP -.->|点卡片直连端口| Svc[Jellyfin :8096<br>Jellyseerr :5055<br>Radarr :7878<br>qBittorrent :8085<br>...]
    style HP fill:#fff3bf
    style VW fill:#fff3bf

实线表示实际经过 Caddy 的请求,虚线表示 Homepage 页面中的导航链接。所有普通 WebUI 都以原生根路径运行,qBittorrent 和 ChineseSubFinder 不再需要适配 URL Base;Vaultwarden 因客户端安全要求继续通过 Caddy 的 8443 端口访问。

4.5 三种入口模型对比

三种入口模型解决的是不同层次的问题,取舍可以归纳如下:

方案要记几个地址网络配置前提条件结局
裸端口N 个主机名:端口地址分散
Caddy 子路径1 个主机名 + N 个路径mDNS每个后端支持 URL Base部分服务无法接入
Homepage 占根1 个首页地址mDNSHomepage 可用与所有原生端口兼容

最终模型把“统一入口”和“统一代理”分开:用户只记住 https://raspberrypi.local/,但各后端继续使用最兼容的原生端口。Caddy 只服务入口本身和 Vaultwarden,入口之内全部直连。

5. 升级 Seerr 与排障实录

系统跑起来之后,又经历了一次大版本升级和一轮集中排障。这一节按“升级 → 联动坑 → 容量教训 → 网络解析 → 队列残留 → 路径统一 → 画质策略 → 字幕断链 → 播放转码”的顺序记录,原因都比症状有趣。

5.1 Jellyseerr 更名 Seerr

Jellyseerr 与 Overseerr 已合并更名为 Seerr:旧镜像 fallenbagel/jellyseerr 停留在 2.7.3,新版本只发布到 ghcr.io/seerr-team/seerr。界面上提示“有更新”时,单纯 docker compose pull 拉到的永远是旧镜像,必须换镜像名。

升级本身是平滑的:ghcr.io 不通时经南京大学 GHCR 镜像站拉取后重新 docker tag 为原名;/app/config 原样挂载,启动时自动执行迁移脚本,请求记录、*arr 关联、代理设置全部保留。唯一的坑是新镜像以 node 用户(uid 1000)运行,而旧镜像写下的配置目录属主是 root,重建前需要 chown -R 1000:1000 配置目录,否则启动即报 EACCES

5.2 Seerr 与 *arr 联动的三个坑

联动配置出错时,症状惊人地一致:请求显示“已批准”,但 Radarr/Sonarr 里毫无动静。三个实际踩过的坑按排查顺序排列:

  1. URL Base 填 / 产生双斜杠。Seerr 用 <baseUrl>/api/v3 拼接 *arr 地址,baseUrl 填 / 会得到 //api/v3。Radarr 对双斜杠路径不报错,而是返回前端页面 HTML:画质配置拉取变成 HTML 字符串,Requests 页渲染时崩溃(profiles.find is not a function),下发请求则在 HTML 里静默失败。没有配置 URL Base 的服务,这个字段必须留空
  2. tagRequests 生成的标签名含空格。Seerr 2.x 会为请求者在 *arr 里创建形如 1 - admin 的标签,而 Radarr 要求标签名小写且不含空格,直接 400 拒绝,影片随之添加失败。Seerr v3 已把标签空格替换为 - 修复;旧版本只能关闭 tagRequests
  3. 已批准的请求不会自动重发。Seerr 只在请求状态发生变化时触发向 *arr 的下发。配置修好后,停留在“已批准”状态的旧请求点 Retry 是空操作(状态没变,什么都不发生),必须删除请求后重新点播。

排障时的可靠顺序是:先看 Seerr 日志里 Media RequestDownload Tracker 两个标签的输出,再到 *arr 里确认影片是否真的添加,最后在 qBittorrent 队列里确认任务是否起来——三个环节各自独立,出问题只会在其中一环。

5.3 剧集“全季请求”的体积教训

电影的体积直觉不适用于剧集。电影一部十几 GB,而剧集请求默认可以一次勾选全部季:一部二十多季的长寿剧,仅 season pack 就是上百 GB。接入 Sonarr 后的第一次批量点播,下载队列瞬间排到一百多个任务,磁盘水位直接告急,只能紧急暂停全部任务再逐个放行。

两条对策:批量点剧之前先看磁盘余量,长寿剧按季请求而不是一次全要;在 Seerr 的用户设置里给家人配请求配额(比如每 7 天限几部电影、几季剧),从源头挡住“刷爆磁盘”式点播。

5.4 mDNS 双网卡导致主机名“时好时坏”

从 Homepage 点开 Jellyfin,偶尔直接报“服务器不可用”,刷新几次又可能恢复;改用 IP 访问则永远正常。症状出在域名解析上,与 Jellyfin 本身无关。

树莓派同时接着两张网卡:有线 end0(192.168.1.x)和 WiFi wlan0(192.168.31.x,另一个网段)。Avahi 默认在所有接口上发布本机地址,包括 WiFi 地址、两张网卡的 IPv6 全局地址、以及一堆 docker/veth 网桥的 fe80 链路本地地址。客户端解析 raspberrypi.local 时拿到哪个地址全凭运气:拿到有线 IPv4 就能通,拿到跨网段的 WiFi 地址或不可达的 IPv6 地址就是“服务器不可用”。mDNS 缓存到期后重新解析的结果仍然随机,所以表现为“修好了还会复发”。

修复是把发布范围收窄到唯一可信的地址,改 /etc/avahi/avahi-daemon.conf

1
2
3
4
5
6
[server]
allow-interfaces=end0        # 只在有线网卡上发布
use-ipv6=no                  # mDNS 只走 IPv4 传输

[publish]
publish-aaaa-on-ipv4=no      # 不再发布 IPv6 的 AAAA 记录

第三个开关最容易漏:use-ipv6=no 只禁用 IPv6 传输,AAAA 记录仍会搭 IPv4 响应的车发出去,必须单独关闭。改完 systemctl restart avahi-daemon,用 avahi-resolve -4/-6 -n raspberrypi.local(来自 avahi-utils 包)验证:IPv4 应只返回有线地址,IPv6 应查无记录;journalctl -u avahi-daemon 里的 Registering 行也能看到实际发布了哪些地址。

排查时有两个误导项值得记下:

  • 在树莓派本机 getent hosts raspberrypi.local 会列出所有接口的地址——那是 systemd nss-myhostname 合成的本机答案,不代表 Avahi 对外发布的内容,不能用来验证修复;
  • 服务端改干净后客户端可能仍然失败,因为设备缓存着旧的错误记录:Windows 跑 ipconfig /flushdns(Chrome 还要在 chrome://net-internals/#dns 清 host cache),手机开关一次飞行模式。Jellyfin 网页端自身还有 service worker 缓存,用无痕窗口测试可以一并排除。

判据始终只有一条:在出问题的设备上 ping raspberrypi.local,看它实际解析到哪个地址。

5.5 删除剧集不会撤回下载任务

在 Sonarr 里删掉一部剧之后,qBittorrent 里属于该剧的任务仍在排队下载。这不是 bug,而是职责边界:Sonarr 只是把磁力链接单向下发给 qBittorrent,后者拿到任务后独立运行,不知道剧集已被删除;Sonarr 删除剧集的对话框只处理自身数据库记录和媒体文件,不会回收已下发的下载任务。

想停下载要分清两种意图:只是不再抓新集数,把剧集改为 unmonitor 即可;连正在下载的任务一起撤掉,要在 Activity → Queue 里删除队列项并勾选 Remove from download client,Sonarr 这时才会调用 qBittorrent 的 API 撤任务。顺序很关键——先删剧集,Queue 记录会随剧集一起消失,剩下的孤儿任务只能去 qBittorrent 里手动清理:WebUI 里按 tv-sonarr 分类过滤后按剧名挑选,或者走 torrents/delete API 按 hash 批量删。

5.6 路径统一:拆掉所有翻译层

Sonarr 曾报过一条警告:download client qBittorrent places downloads in /downloads but this directory does not appear to exist inside the container。直接解法是给 Sonarr 加一条 Remote Path Mapping(“qBittorrent 说 /downloads 时,到 /share/Downloads 找”),警告当天就消了。但这条警告只是症状:当时同一个文件在每个容器里的路径都不一样——qBittorrent 说 /downloads,Radarr 靠第一篇第 10 节的软链脚本说 /movies/downloads,Sonarr 说 /share/...,Seerr 给两边各填一种,Bazarr 又单独挂载跟随 Radarr。软链和远程路径映射这些“翻译层”存在的唯一原因就是路径不统一,每加一层就多一处静默故障点。

翻译层只治标。根治是把全链路迁到同一个绝对路径:电影 /share/Movies、剧集 /share/Video/Series、下载 /share/Downloads,任何容器看到的都是物理路径本身,然后拆掉所有翻译层。第一篇 10.4 没做这套“迁移手术”是因为当时判断风险大于收益;有了 API 批量操作的经验后,实际做下来半小时内完成:

  1. qBittorrent 对齐路径:加挂 /share:/share,默认下载路径改为 /share/Downloads;队列里 29 个存量任务用 torrents/setLocation 批量迁移(物理是同一目录,文件零移动),随后摘除 /downloads 旧挂载。
  2. Radarr 批量改写库记录:新增 root folder /share/Movies,用 API 把 23 部电影的 path/movies/... 改写为 /share/Movies/...moveFiles=false,文件不动只改记录),再删旧 root folder。两个隐藏引用也要一并修:电影合集的 rootFolderPath 和一个已禁用的 CouchPotato 导入列表残留——漏掉它们,Health 会持续报“缺失根目录”。
  3. 下游全部对齐:Seerr 的 radarr activeDirectory 改为 /share/Movies;Bazarr 挂载改为整挂 /share:/share。Bazarr 这一步还有附带收益:它之前只挂了 /movies/downloads,Sonarr 上报的 /share/Video/Series 它根本看不见——剧集字幕其实一直是断的,路径统一后才接上。
  4. 拆除翻译层:删掉软链脚本和两条远程路径映射,重建容器。

验收标准:Radarr、Sonarr 的 Health 均零警告;有文件的影片刷新后全部正常识别;新下载直接落在 /share/Downloads,导入硬链接照常工作。至此系统里不存在任何路径别名——这也印证了第一篇 10.4 的判断:从零搭建的系统本就不需要软链,这次迁移等于把存量系统迁回了它本该长成的样子。

迁移还有一个小余波值得记录:一批在迁移前抓取、尚处 0% 排队状态的剧集任务,在 setLocation 验证全部成功、摘除旧挂载并重建容器之后,有 11 个的保存路径悄悄退回了 /downloads,随后因路径不存在进入 error 状态——qBittorrent 对未启动任务的 fastresume 没有及时落盘的嫌疑最大,但机制未能完全坐实。处置方式是把 error 任务连同 Sonarr 队列记录一并删除,对受影响的集重新 EpisodeSearch;Sonarr 的失败下载处理同期也会自动重抓,两边重叠产生的重复任务按 Sonarr 队列实际跟踪的 hash 决定去留。教训是:批量改动下载路径后,不要只验证“改完的那一刻”,重启容器后应对照再查一遍任务状态。

5.7 画质天梯、死种与做种数下限

接入 Sonarr 后,参照 Radarr 的 1080-4k 建了同名画质 profile(同时允许 1080p 与 2160p,cutoff 为 2160p Remux,允许升级),并设为 Seerr 的默认。顺带发现一个此前埋着的雷:Seerr 里 Sonarr 的旧默认 profile 是 Ultra-HD,它只接受 2160p——只发行了 1080p 的剧按这个默认走会永远等 4K、一集不下。

画质 profile 的本质是一个固定偏好天梯:位置高的永远优先,做种数、下载速度都不参与排序。由此引出两个重要推论:

  • 不存在“哪个下得快下哪个”的配法。想让 1080p 和 4K 里挑健康的下,天梯做不到——它不知道什么叫健康。
  • 也不存在“1080p 先看、4K 后补”的配法。把 1080p 排到 4K 上面,Sonarr 会认定 1080p 更优,拿到就彻底满足,永远不再找 4K;升级只会朝天梯上方走。

天梯策略唯一的软肋是死种:4K 优先时,一个做种为 0 的 2160p 资源照样排在 118 做种的 1080p 前面。《The Last of Us》S02E01 就撞上了:Sonarr 抓的 APEX 2160p 是死种,交互式搜索列出 77 个候选却全部被拒——死种占位(“队列中已有同等或更高偏好”)、磁盘不足(“下载后超出可用空间”,当时磁盘 97%)、画质白名单三重拦截叠加。删掉死种队列项并拉黑、手动改抓 118 做种的 1080p 后,下载瞬间起速。

死种的根治手段是给索引器设最少做种数:Sonarr 与 Radarr 的全部索引器统一设 minimumSeeders = 3 后,做种数不达标的资源在决策时直接出局,4K 死种不再占位,自动回落到健康的 1080p。需要注意两点:判断依据是索引站上报的做种数,本身有数小时延迟,个别“虚报”仍会漏进来;半死不活(1-2 个做种慢慢吊着)的资源 Sonarr 不会自动放弃,队列里长期 stalled 的任务仍需人工介入。

5.8 剧集字幕断链:Bazarr 的 Sonarr 残次配置

剧集接入后暴露过一个迷惑性很强的症状:《The Last of Us》第二季第一集有中文字幕,第二集却只有英文。表层原因是资源差异——E01 的官方 WEB-DL 内嵌 35 条字幕轨(含 3 条中文),E02 的压制资源只内嵌英文。但顺着往下挖,发现字幕管线根本没接通剧集:

  1. Bazarr 的 sonarr 段是指向 Radarr 的残次配置。第一篇部署 Bazarr 时还没有 Sonarr,第二篇加了 Sonarr 之后没人回来接它。config 里那段 sonarr:ip 填的是 radarr、API key 是 Radarr 的、总开关 use_sonarr: false——三项全部修正后,剧集才同步进 Bazarr。
  2. 剧集没有分配字幕语言 Profile。电影已经使用“原版字幕:中文优先,英文兜底”Profile(zh → en,cutoff 为 zh),剧集的 profileId 却是 None,Bazarr 即使同步了剧集也认定“什么都不缺”。为电影和剧集同时启用默认 Profile,并给存量剧集批量分配后,各集才正确标出 missing: ['zh']
  3. 字幕源限流制造了“隔集有字幕”的假象。补齐配置后的首轮搜索按集排队进行:靠前的集用光了 opensubtitlescom 的每日配额(DownloadLimitExceeded,封到次日),zimuku 又触发反爬(AnticaptchaException),轮到靠后的集时无源可用。最终 E01/E03 靠字幕站、E05/E07 靠提取 mkv 内嵌中文轨补齐,E02/E04/E06 只能等配额重置后自动重试。

这次排障还澄清了两个机理。其一,每集的字幕有两个来源:mkv 内嵌提取与字幕站下载;同一发布组每集的封装都可能不同(E05 内嵌中文轨,同组的 E04/E06 就没有),所以“同组同待遇”不成立。其二,Sonarr 的抓取单位是集而不是季:每集独立按天梯选秀,同一季来源自然五花八门;想要整季统一只能指望 season pack(一个组出一整季),或者等升级机制向高位画质慢慢收敛。

5.9 播放端转码:FFmpeg 为什么拉满 CPU

在 Jellyfin 看片时发现 CPU 被 FFmpeg 占满。Jellyfin 的默认路径是 DirectPlay(原文件直推客户端,服务端几乎零开销),FFmpeg 出现即意味着实时转码;树莓派没有可用的硬件编码,软编 x264 必然拉满 CPU。

转码日志还原了触发原因:播放的是 H264 1080p——最兼容、本该直放的格式,但 FFmpeg 命令行里是 libx264 软编码、画面缩至 1280 宽、码率 3.6M、5.1 声道混成立体声,即 Jellyfin 的“720p 4Mbps”档。客户端把播放质量限到了 720p(或 Auto 档误判带宽),一旦限制低于源画质,格式再兼容也必须现场重编码

实际触发转码的通常是这三类情况:

  • 客户端画质档位限制:手动选了低档,或 Auto 档对带宽误判;
  • 浏览器解码短板:Chrome 不支持 HEVC(x265)、TrueHD/Atmos/DTS 音频,而 Remux/4K 资源多为 x265,浏览器播放必转码,4K 转码树莓派根本无力承担;
  • 图形字幕烧录:ASS/PGS 字幕必须烧进画面,强制视频转码;外挂 SRT 更容易直放,本系统优先保留字幕站返回的原版 SRT。

结论:在树莓派上,直放不是优化项而是必须项——播放器画质选“自动”或最高档;看 x265/4K 用官方客户端(Jellyfin Media Player、手机 App、Android TV、Kodi、Infuse)而不是浏览器;字幕优先外挂 SRT。DirectStream(仅换封装、不重编码)同样几乎零开销,也是可接受的中间态。

6. 当前架构与扩展方向

整合后的 homelab 可以分成三层:Jellyseerr、Homepage 和 Bitwarden 客户端直接面向使用者;Jellyfin、Radarr、Sonarr 等应用提供播放、下载和管理能力;Caddy、Vaultwarden、AdGuard Home 与 V2Ray 提供入口、凭据和网络支撑。各组件保留独立职责,通过明确的接口连接,而不是由一个服务包办全部功能。

当前服务分布如下:

分组服务端口
播放与点播Jellyfin、Jellyseerr8096、5055
下载管线Radarr、Sonarr、Jackett、qBittorrent、Bazarr、ChineseSubFinder7878、8989、9117、8085、6767、19035
入口与管理Homepage、Vaultwarden、Caddy、Portainer3001(经 443 反代)、8443(HTTPS 反代)、80/443、9443
网络AdGuard Home、V2Ray53/3000、10808/10809

6.1 日常使用与维护重点

家庭成员日常只需要记住 https://raspberrypi.local/:进入 Homepage 后选择 Jellyseerr 点播或打开 Jellyfin 播放。管理员也从同一页面进入各个 WebUI,Bitwarden 扩展按网址匹配并填写对应的独立密码。

稳定运行依赖三项持续维护:

  • 定期备份各容器的配置目录,尤其是 Vaultwarden 的 /home/pi/docker/vaultwarden/data
  • 新增服务时先确定它属于用户入口、业务应用还是基础支撑,再决定是否出现在 Homepage;
  • 播放端尽量使用 Jellyfin 直接播放,避免把树莓派变成实时转码服务器。

6.2 可继续补充的服务

下面这些 ARM64 服务可以沿着现有分层继续扩展,不会改变核心链路:

  • Prowlarr:Jackett 的现代化替代,索引器统一管理并同步给所有 *arr;
  • Recyclarr:把 TRaSH Guides 推荐的画质配置自动同步进 Radarr/Sonarr;
  • Navidrome:音乐流媒体,私人 Spotify;
  • Immich:自托管 Google Photos,8G 内存能跑;
  • Uptime Kuma / Dozzle:服务存活监控 / 浏览器里看容器日志。

Tdarr 依赖大量转码算力,不适合把树莓派作为主要处理节点;Nextcloud 虽然可以运行,但资源占用和维护复杂度都明显高于当前服务。扩展时仍应优先选择职责单一、支持 ARM64、可以复用现有目录与入口模型的组件。

本文由作者按照 CC BY 4.0 进行授权