自由翱翔：十年学习成长之路 - 技术专业
iPhone 网络定位测试：用 Quantumult X 配置 WLOC 的一次记录
今天尝试了一个 iPhone 网络定位测试方案：通过 Quantumult X 拦截苹果的网络定位请求，替换 Wi‑Fi/基站定位返回的坐标。它不是修改 GPS 硬件信号，而是影响苹果网络定位服务的结果。因此在室内、GPS 信号较弱时更可能有效；如果在室外，真实 GPS 信号很强，地图 App 可能仍优先使用真实位置。配置过程使用的工具是 Quantumult X，并导入 WLOC 的重写资源：https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf导入时需要放在 Quantumult X 的“重写资源（Rewrite Resources）”中，而不是节点订阅。导入成功后，MITM 主机名中会自动出现：gs-loc.apple.com gs-loc-cn.apple.com这两项配置正确，且应避免添加 *.apple.com 或 * 之类的全域规则，以免扩大 HTTPS 解密范围。证书配置由于定位响应使用 HTTPS，Quantumult X 必须生成并安装自己的 CA 证书。流程如下：在 Quantumult X 的 设置 → MitM 中生成 CA。选择安装证书。在 iPhone 的 设置 → 通用 → VPN 与设备管理 中安装描述文件。到 设置 → 通用 → 关于本机 → 证书信任设置，开启 Quantumult X CA 的“完全信任”。回到 Quantumult X，打开 MitM、Rewrite 和 WLOC 重写资源，并启动连接。如果 Quantumult X 已有 CA，不必重复生成；重新生成会让旧证书失效，需要重新安装和信任。设置测试坐标项目提供了“wloc 设置地理位置”快捷指令。安装后，需要让它出现在 iOS 共享菜单中：打开“快捷指令”App，进入该快捷指令的详情。开启“在共享表单中显示”。接收类型选择 URL/链接。之后可以在苹果地图或高德地图中选点，点击“共享”，选择“wloc 设置地理位置”。快捷指令提示“已更新位置”，只表示目标坐标已写入 Quantumult X 的持久化存储，并不代表地图一定已经显示新位置。快捷指令跳转到定位服务页面执行快捷指令后，系统会停留在“定位服务”设置页面。这不是报错，而是为了刷新定位权限和系统缓存。可以按以下顺序操作：保持 Quantumult X 已连接，确认 Rewrite、MitM、WLOC 都已开启。关闭“定位服务”总开关。完全退出高德地图和苹果地图。重新打开“定位服务”。若 App 再次请求定位权限，可先选择“下次询问或在我共享时”。优先用苹果地图验证，再测试高德地图。如果系统版本较新、地图仍显示旧位置，重启手机往往更有效。重启后，应先启动 Quantumult X 并确认 VPN 图标出现，再开启定位服务和地图。恢复真实位置测试完成后，应运行“清理恢复位置”快捷指令，关闭或删除 WLOC 重写资源。若地图仍显示旧位置，重启手机后再确认。不再测试时，建议关闭 MitM，并撤销 Quantumult X CA 的完全信任或删除证书。因为 HTTPS 解密证书属于高权限配置，应只保留在自有设备、授权测试场景中。
最近跟风买了一个大疆的 4G 模块。一开始我只是想看看它能不能接到 NAS 上，用来做一个短信收发管理工具。结果一路折腾下来，发现这东西确实有点意思，但也不是“插上就能用”。一开始的目标我的初始目标很简单：把这个大疆 4G 模块接到自己的设备上，实现短信集中收发管理。最好可以放在 NAS 上，因为 NAS 长期开机、管理方便，也适合跑一些 Web 服务。后来又看到网上有人把这个模块识别成 Quectel EC25 后，配合 VoHive 使用，可以管理短信、eSIM、设备状态，甚至通过 Telegram 做通知，于是就开始评估是否要刷机或改设备信息。要不要刷机？刚开始比较担心“刷机”这个词，因为涉及固件、NV 参数之类的东西，风险不低。后来确认下来，这次并不是传统意义上的刷固件，而是通过 AT 命令修改 USB 识别信息。这个大疆 4G 模块本质上是移远 Quectel EG25-G 的变体，默认 USB ID 是大疆私有的：2ca3:4006 修改后可以让系统把它识别成更通用的 Quectel EC25：2c7c:0125 修改之后，Linux 内核就能比较自然地加载 option、qmi_wwan、cdc_wdm 等驱动，设备会出现：/dev/ttyUSB* /dev/cdc-wdm0 wwan0 这一步完成后，可玩性大幅增加。为什么最后没有放在 NAS 上？一开始我确实更想把它插在 NAS 上。NAS 稳定、安静、长期在线，看起来是最合适的宿主。但实际测试后发现，我的 NAS 虽然能看到 USB 设备本身，却缺少关键内核模块，比如：option qmi_wwan cdc_wdm usb_wwan 也就是说，NAS 能看到“有个 USB 设备插进来了”，但不能把它识别成可用的蜂窝模块、串口设备或 QMI 网络设备。这就很尴尬了。VoHive 即使跑在 NAS 上，也无法真正接管这个模块。所以最后确定方案是：模块接在 OpenWrt 路由器上，VoHive 也部署在 OpenWrt 上；NAS 如有需要，只做反向代理或访问入口。这个方案反而更合理。路由器本来就是网络设备，OpenWrt 对 USB 网卡、QMI、串口这些支持也更直接。部署到 OpenWrtOpenWrt 上识别成功后，模块状态基本正常：2c7c:0125 /dev/cdc-wdm0 /dev/ttyUSB0 - /dev/ttyUSB3 wwan0 之后在 OpenWrt 上部署 VoHive。VoHive 提供了一个 Web 管理界面，可以看到模块状态、SIM 信息、短信中心、eSIM 页面、通知配置等功能。最后访问地址是：[http://192.168.5.1:7575](http://192.168.5.1:7575) 部署完成后，VoHive 可以正常启动，也能识别模块。修改设备信息后的变化把设备从大疆私有 ID 改成 Quectel EC25 兼容身份后，最大的变化是系统识别能力明显增强。原来它更像是一个“只服务于大疆生态的模块”；修改后，它变成了一个可以被 Linux 正常管理的蜂窝模块。可以实现的东西包括：识别 SIM 卡信息查看信号强度、网络状态、注册状态管理短信收发通过 QMI 管理蜂窝模块配合 VoHive 做 Web 化管理配置 Telegram、Webhook、Email 等通知理论上支持 eSIM/eUICC 卡管理使用支持蜂窝注册的 SIM 时，还可以进一步配置数据联网这一步算是整个折腾里最关键的分水岭。目前实现的使用场景目前我已经实现的场景是：把一张能正常注册蜂窝网络的 SIM 卡插在大疆 4G 模块里，通过 OpenWrt 上的 VoHive 实现短信集中收发管理。现在可以在 Web 页面里查看短信，也可以配置 Telegram Bot，把收到的短信推送到 Telegram。这样一来，一些验证码、通知短信、备用号码短信，就不用再依赖某一台手机常年开机插卡了。模块插在路由器上，VoHive 负责管理，Telegram 负责提醒，整体体验还挺顺手。
折腾记：自建 peer relay 与 DERP，把 Tailscale 内网延迟从 300ms 打到 30ms
起因手机上通过某第三方客户端内嵌的 Tailscale 模块连回家里内网，延迟高得离谱——tailscale ping 动辄 300ms 起步，还会抖到 1.6 秒，速度慢到基本不可用。表面是"网络慢"，深挖下去是一整条链路的问题。这篇记录完整的排查与解决过程，也算给自己留个底。第一步：定位根因——中继落在境外高延迟在 Tailscale 场景里几乎都指向同一个根因：流量走了 DERP 中继，而不是 P2P 直连。先在 NAS 上定位：tailscale ping 100.x.x.x # pong from node via DERP(hkg) in 381ms ... direct connection not establishedvia DERP(hkg)——中继落在境外节点。再看 netcheck：Nearest DERP: Hong Kong hkg: 145.6msNAS 到官方境外 DERP 就要 145ms，手机（蜂窝网络）到该节点又是一段，两段叠加加抖动，300ms+ 就是这么来的。P2P 直连没打通，全程依赖这个远距离中继。结论：得把中继点从境外换到境内。第二步：尝试 peer relayTailscale 的 peer relay（对等中继）走 UDP，延迟特性接近直连，比 DERP（TCP/TLS）更优，是"打洞失败但还没沦落到 DERP"的中间层。我在一台境内云服务器上部署它。踩坑记录一开始以为打了 tag:relay 标签就算部署好了，实际差得远：tailscale debug prefs 里 grep 到的 tag:relay 只是身份标签，不是 relay 服务本身；peer relay 真正的启用是写入 RelayServerPort：tailscale set --relay-server-port=41641启用后确认：tailscale debug prefs | grep -i relay # "RelayServerPort": 41641 ss -lunp | grep 41641 # tailscaled 在 41641/udp 上 LISTEN（v4+v6 双栈）ACL 里也补上了 grant："grants": [ { "src": ["*"], "dst": ["tag:relay"], "app": { "tailscale.com/cap/relay": [] } } ]云服务器安全组放行 41641/UDP（这台没装 ufw，系统层不拦，但云平台安全组是独立一层，必须在控制台放行）。验证服务端本身没问题关键的一步——从 NAS 直接 ping 这台 relay：tailscale ping 100.x.x.x # pong from relay-vps via <公网IP>:41641 in 17msdirect UDP 直连，17ms，走的正是 41641。 说明服务端、端口、安全组、端点宣告全部正常。再 ping 另一台海外机器（官方客户端）也是 direct。但手机就是走不通配置全齐了，手机的 tailscale ping 依然死守 via DERP(hkg)。做了对照实验后规律清晰：链路结果性质NAS ↔ 境内 VPS17msdirect ✅NAS ↔ 海外机器186msdirect ✅NAS ↔ 手机(第三方客户端)300ms+via DERP(hkg) ❌所有官方 Tailscale 客户端之间都能 direct，唯独跟手机（第三方客户端内嵌模块）之间只能走 DERP。 问题不在服务端，在那个第三方客户端的内嵌 Tailscale 实现——它不支持 peer relay 协商。这个变量我控制不了。第三步：自建 DERP（derper）——不挑客户端的兜底方案peer relay 依赖对端支持，而对端是第三方客户端，这条路对我的实际场景走不通。转向 derper：它是 DERP 协议，所有 Tailscale 客户端天生都会用，包括那个内嵌模块，配好即用、必然生效。而且实测这台境内 VPS 到我家公网 IP 只有 14ms、到 NAS 17ms（netcheck 显示"最近 DERP 在境外"是被国际出口策略绕路误导，真实 UDP 路径是境内直达的），位置其实极佳，正是理想的 derper 载体。部署要点（derper 独立跑 8443，与 Web 服务的 443 共存）443 端口被这台上的 Caddy 占用（跑网站），所以让 derper 跑 8443，两者井水不犯河水。证书：让 Caddy 负责签发和续期，derper 用 manual 模式读现成证书。在 Caddyfile 加一个块为你的 DERP 子域名签证书。这里踩了个低级坑：一开始把子域名的后缀写错了，Caddy 一直报 NXDOMAIN。改对后证书秒签。证书同步脚本（应对 Caddy 每 60 天续期）：#!/bin/bash DOMAIN=derp.example.com # 替换成你自己的域名 SRC_CRT=$(find /var/lib/caddy -name "${DOMAIN}.crt" 2>/dev/null | head -n1) SRC_KEY=$(find /var/lib/caddy -name "${DOMAIN}.key" 2>/dev/null | head -n1) DST=/var/lib/derper if ! cmp -s "$SRC_CRT" "$DST/${DOMAIN}.crt"; then cp "$SRC_CRT" "$DST/${DOMAIN}.crt" cp "$SRC_KEY" "$DST/${DOMAIN}.key" systemctl restart derper 2>/dev/null || true fi配 systemd timer 每天同步一次。装 derper（这台没有 Go，得先装；境内网络用国内镜像下 Go、goproxy.cn 拉依赖）：wget https://mirrors.aliyun.com/golang/go1.25.5.linux-amd64.tar.gz tar -C /usr/local -xzf go1.25.5.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin go env -w GOPROXY=https://goproxy.cn,direct go install tailscale.com/cmd/derper@latest cp ~/go/bin/derper /usr/local/bin/derperderper 的 systemd 服务：[Service] ExecStart=/usr/local/bin/derper \ -hostname derp.example.com \ -a :8443 \ -http-port -1 \ -certmode manual \ -certdir /var/lib/derper \ -stun -stun-port 3478 \ -verify-clients Restart=always几个关键 flag：-http-port -1 禁掉 derper 自己的 80 监听避让 Caddy；-verify-clients 让它只给自己 tailnet 的节点中继、防被白嫖（靠本机已在 tailnet 里的 tailscaled 校验身份）。安全组：放行 8443/TCP 和 3478/UDP。ACL 的 derpMap（RegionID 必须 ≥900 避免和官方冲突）："derpMap": { "OmitDefaultRegions": false, "Regions": { "900": { "RegionID": 900, "RegionCode": "cn-self", "RegionName": "Self Hosted", "Nodes": [{ "Name": "900a", "RegionID": 900, "HostName": "derp.example.com", "DERPPort": 8443, "STUNPort": 3478 }] } } }验证成功服务端起来后 curl -vk https://derp.example.com:8443/ TLS 握手成功、返回标准的 "This is a Tailscale DERP server" 页面。NAS 上 netcheck：- cn-self: 15.8ms (Self Hosted) ← 自建 DERP - hkg: 158.1ms (Hong Kong) ← 官方境外，慢 10 倍NAS 侧最近的 DERP 变成了自建节点，15.8ms，把境外的 158ms 打得稀烂。第四步：真正的最后一公里——换客户端derper 建好了、NAS 侧也选中了自建节点，但手机的 tailscale ping 依然 via DERP(hkg)。进一步验证：手机的 Relay 字段仍钉在境外节点；但手机浏览器能打开 DERP 的 HTTP 页面——网络完全可达。实锤了：那个第三方客户端内嵌的 Tailscale 模块既不支持 peer relay，也不加载自定义 derpMap。 这不是服务端能修的，是那个客户端实现的天花板。它消费不了我辛苦搭好的基础设施。解法很简单：手机装官方 Tailscale App。 一换客户端，天翻地覆：tailscale ping 100.x.x.x # pong from iphone via peer-relay(192.168.x.x:40000:vni:xxxx) in 33ms设备从第三方客户端变成官方 App；延迟从 300ms+ 直接砍到 25–41ms；而且走的是 peer-relay——官方客户端正确支持了 peer relay，反过来 100% 印证了之前的判断：peer relay 服务端早就配对了，一直走不通就是那个第三方客户端的锅。至此，peer relay 和 derper 两套建设成果同时兑现，没有一步是白做的。复盘：证据链与教训整条排查线的核心结论：服务端每一环都建对了、也验证了（自建 DERP 15.8ms、NAS↔VPS 17ms direct、derper TLS 正常、peer relay 端口通），唯一自始至终的短板是那个第三方客户端内嵌 Tailscale 模块的能力缺陷。几条经验：高延迟先看 tailscale ping 的 via 字段——direct / via DERP / via peer-relay 决定了后面所有动作。netcheck 的"最近 DERP"可能被国际出口策略误导，真实链路质量要用 ICMP/UDP 直连实测（这台 VPS 就是典型：netcheck 说境外最近，实测到家 14ms）。peer relay 依赖两端客户端都支持，是它的脆弱点；DERP 不挑客户端，是它可靠的原因。追求低延迟用 peer relay，追求稳定用 derper，两者可以共存。境内自建 DERP 是国内用 Tailscale 的关键优化——官方 DERP 最近的节点也在 150ms 级别，自建境内节点能压到十几毫秒。客户端选型比服务端调优更决定成败。折腾了一整晚服务端，最后发现最有效的动作是"换回官方 App"。内嵌 Tailscale 的第三方客户端目前普遍不成熟，涉及 Tailscale 高级特性时，官方客户端才是正解。安全性备注自建 DERP 的主要滥用面是"别人白嫖你的带宽做中继"，-verify-clients 已经堵死——它只为通过本机 tailscaled 验证身份的节点（即自己 tailnet 的成员）中继。前提是这台的 tailscaled 要保持在线，否则验证链会断。STUN（3478）是公开的但无害（放大系数≈1）。HTTP 说明页公开但不含敏感信息。顺手做了个探活脚本，覆盖 derper 服务、tailscaled 在线、8443 可达、证书有效期四项，异常就消息推送告警，给"境内单点 DERP 一挂中继全回退境外"的风险兜底。最终架构手机（官方 Tailscale App） ├─ 家里 WiFi → peer-relay（内网直连接力）→ 30ms └─ 外出蜂窝 → 自建 DERP（境内中继） → 十几~几十ms内网访问（SSH 到 NAS、内部服务）：官方 Tailscale App，配成按需/仅内网生效以省电；网络分流：交给专门的分流客户端，各司其职。两套分工，各干各的。最初"手机连内网延迟高、速度慢"的问题，彻底解决。
最近看到 Immich 正式发布了 3.0 版本。作为我 NAS 上最重要的应用之一，自然第一时间关注了一下这次的大版本更新。在升级之前，我先对比了一下 3.0 与我目前使用的 2.7 版本有哪些值得升级的新功能。为什么升级？官方这次 3.0 并不是一次简单的版本更新，而是一次比较大的功能升级。比较吸引我的几个新特性包括：内置 AI 修图（Image Editing），可以直接在 Immich 中完成简单的照片编辑，而不用再导出到其它软件。更完善的照片管理能力，包括更好的浏览体验和细节优化。搜索和 AI 能力持续增强，后续还有更多基于视觉模型的扩展空间。底层架构升级，数据库向量引擎等组件进行了优化，为后续功能提供基础。对于我来说，最有价值的还是修图功能。平时大量照片都是手机自动同步到 NAS，偶尔需要裁剪、调整亮度或者做一些简单编辑，以前都需要下载到手机或电脑处理，再重新上传。现在如果能够直接在 Immich 中完成这些操作，整个照片管理流程会方便很多。综合评估之后，我决定将家里的群晖 DS218+ 升级到 Immich 3.0。我的环境升级前的环境如下：NAS：Synology DS218+DSM 7Docker Compose 部署Immich 2.7.xPostgreSQL 官方数据库镜像十万余张照片AI 人脸识别已完成由于图库已经比较大，所以升级之前还是决定按照官方建议，先做好完整备份。升级前检查首先确认当前的 Docker Compose 配置。检查发现，我使用的已经是官方新版 Compose 文件，数据库也已经采用了新的 VectorChord 镜像，因此官方升级文档中关于数据库迁移的步骤并不适用于我的环境，不需要再修改数据库配置。随后备份了几个关键文件：PostgreSQL 数据库docker-compose.yml.env这样即使升级过程中出现问题，也能够快速恢复。开始升级升级过程实际上非常简单，基本按照官方文档执行即可。首先拉取最新镜像：docker compose pull随后重新启动容器：docker compose up -d整个升级过程中唯一遇到的问题，是下载镜像速度比较慢。Immich 镜像托管在 GitHub Container Registry（GHCR），其中 immich-server 镜像有一个三百多 MB 的 Layer 下载速度非常慢，在我的网络环境下等待了相当长时间才完成。这一点需要一点耐心，或者提前为 NAS 配置代理。升级后的第一次启动镜像更新完成后，我立即访问：http://NAS_IP:2283结果浏览器一直打不开。第一反应还以为升级失败了。随后开始逐步排查。首先检查容器状态：docker compose ps发现：PostgreSQL 正常Redis 正常Machine Learning 正常Immich Server 已经启动，但状态仍然是 health: starting继续查看日志：docker logs immich_server日志显示：Running migrations同时可以看到系统正在处理十万多张照片对应的人脸索引和向量索引。这才意识到，并不是升级失败，而是 Immich 3.0 第一次启动正在执行数据库迁移和索引检查。对于只有两核 CPU 的 DS218+ 来说，这一步需要一定时间。只要耐心等待，服务完成初始化后，健康状态会从 starting 变成 healthy，网页也会恢复正常访问。总结整个升级过程比想象中顺利，没有遇到兼容性问题，也不需要重新导入照片或重新识别人脸。目前 Immich 已经逐渐从一个优秀的开源相册，发展成了一套功能越来越完整的个人照片管理平台。对于我来说，这次升级最大的期待，就是能够在 NAS 上直接完成一些日常修图操作，进一步减少照片在不同设备之间来回导出的流程。
Podsync 的一次"空时长"崩溃排障：从 bad format string 到 PO Token 补全
家里 NAS 上跑着三个 podsync 容器，把一个频道转成播客音频喂给我的播客客户端。这套东西平时很省心，直到某天日志里开始反复刷同一条错误。这篇是完整的排障记录——之所以写下来，是因为过程里有三次方向判断被推翻，而每一次被推翻，都比一开始"猜对"更能说明这个系统到底是怎么运转的。一、现象日志里，某一个 feed 每轮更新都失败，其它 feed 正常：ERRO ... failed to update feed: https://www.youtube.com/channel/UC.../videos error="update failed: failed to parse duration : bad format string"注意报错里 duration 后面那个值是空的。而且它只影响 A01——后面的 A02、A03 照常下载，说明 podsync 是逐个 feed 更新的，一个挂了不拖累其余。损失面很小，但这条错误会一直刷，且只要触发条件在，就不会自愈。二、定位：三次推翻第一次判断：加个过滤把它挡掉 —— 失败第一反应是：列表顶部大概有一个"未开播的首播/直播预告"，它没有时长，把解析撑爆了。那就用 yt-dlp 的 match-filter 把这类视频过滤掉。于是往该 feed 的 youtube_dl_args 里加了：youtube_dl_args = [..., "--match-filter", "live_status!=is_upcoming"]重启后，报错依旧。第二次判断：过滤发生的阶段不对 —— 部分正确，但仍不完整查文档后明白了：youtube_dl_args 只在下载阶段传给 yt-dlp，而崩溃发生在更早的列表构建阶段。过滤加在了下载侧，够不到列表侧的解析，自然没用。同理，podsync 自带的 filters（标题、时长过滤）也是解析完之后才执行的，一样挡不住。这一步方向对了，但我当时以为列表也是 yt-dlp 抽的。直到看清报错本质，才有了第三次、也是最终的定位。第三次判断（最终）：这是 Go 二进制里的 bug，配置无解关键线索是 bad format string 这个措辞——它来自 podsync 解析 ISO8601 时长（PT#H#M#S 那种格式）时对空值的报错。而 ISO8601 时长只有 YouTube Data API 才会返回；yt-dlp 给的时长是浮点秒数，不长这样。于是链路彻底清楚了：拉列表 = podsync 调 YouTube Data API，逐个解析视频的 contentDetails.duration（ISO8601）；下载 = yt-dlp（cookies / node / GetPOT 都在这一侧）。那个频道当时挂了多个长周期的预约首播（首播时间排到第二天），这些未开播视频的 API 时长是空字符串，podsync 老版 Go 二进制拿到空值直接抛 bad format string、整条 feed 崩。结论：崩溃在 podsync 的 Go 代码里，任何配置层面的过滤都救不了它。 一个讽刺的旁证是——那两条卡住我的预告标题里都带"XX分钟"，而我配置里本就写了 not_title = "XX分钟" 想排除它们；但解析崩在过滤之前，连执行这条排除规则的机会都没有。查到官方 issue #638 正是这个问题，且已 Closed 但没有关联主线 PR——最合理的解读是：提出者（fqx，同时是 podsync-with-yt-dlp fork 的维护者）在自己 fork 里修好后关掉了上游 issue，主线从未合并这个补丁。所以升级官方镜像大概率没用，补丁只在 fqx 的 fork 里。三、方案：换一个"能容忍空时长"的构建确认我跑的是三年前的官方 mxpv/podsync:latest（Docker Hub），且 :latest tag 不会自动刷新运行中容器的 Go 二进制——yt-dlp 靠 self_update 一直是新的，但 podsync 本体一直是老的。这解释了为什么它一边下载正常、一边解析崩溃：下载能力和崩溃点是两条独立的线。走弯路：把新二进制塞进老镜像 —— 失败第一个想法是外科手术式的：用多阶段构建，只把 fqx 修好的 podsync 二进制 COPY 进我现在这个能正常下载的老镜像，其余不动。FROM ghcr.io/fqx/podsync-with-yt-dlp:latest AS patched FROM mxpv/podsync:latest COPY --from=patched /app/podsync /app/podsync构建成功，但一启动：exec /app/podsync: no such file or directory文件明明在。这个"no such file or directory"其实是 Linux 上缺动态链接库/loader 最典型的误导性报错——fqx 二进制（2024 环境）和三年前老镜像的 musl/libstdc++ 版本代差太大，依赖对不上。跨代基础镜像移植二进制，不可靠。正解：以 fqx 镜像为运行时基底反过来做——直接用 fqx 镜像当运行时，不再往老镜像塞东西。它自带能跑的二进制和配套环境，而 podsync 的 self_update 会在启动时把内置的老 yt-dlp 自动升级到最新。测试容器起来后：二进制正常启动（印证了之前是"缺库"）；yt-dlp 从 2024.05.27 自更新到 2026.07.04（与原来完全一致）；A01 不再有 bad format string，正常 building feed。主问题解决。随后把三个容器逐个切到 fqx 镜像。有个必须守住的铁律：三个容器挂同一个 /app/data，podsync 的 badger 数据库是单写锁，切换必须逐个来——先停旧、再起新，任何时刻同一份 data 只能有一个 podsync 进程。切换时还踩到一个小坑：直接从挂载卷拷现成的 node 二进制会报 symbol not found——因为那个 node 是为旧镜像的 libstdc++ 编译的。解法是先在新容器里 apk add --upgrade libstdc++ 把库升上去，node 就能加载了。四、意外的续集：PO Token 其实一直是空的迁移完想确认下载防护（GetPOT）还在不在，结果探针接连翻车：手敲 yt-dlp ... 报 executable not found——因为 podsync 沿用老名字 youtube-dl，路径也不在默认 PATH；用对名字后终于看到关键一行：[debug] [youtube] [pot] PO Token Providers: none注意是 Providers: none。 这里要区分两个概念：Extractor Plugins: GetPOT 只代表 GetPOT 这个框架加载了，但框架背后需要一个真正干活的 provider 才能产出 PO Token。三个容器都是 none——也就是说框架在、但没有 provider，PO Token 实际并没在生成。之前下载还一直正常，是因为 yt-dlp 自身还有基于 EJS 的基础机制兜底，加上 YouTube 当时没对这些请求强制要 PO Token。属于"能跑但不够稳"。既然要补全，就上标准方案。补全：bgutil PO Token provider（HTTP 模式）架构是：单独跑一个 bgutil provider 容器（HTTP 服务，端口 4416），三个 podsync 共用它。起 provider 容器：docker run --name bgutil-provider -d --restart unless-stopped --init \ -p 4416:4416 brainicism/bgutil-ytdlp-pot-provider:latest每个 podsync 容器里装 plugin 端（fqx 镜像只带框架、不带这个 provider 的 plugin）：docker exec <容器> python3 -m pip install -U bgutil-ytdlp-pot-provider给每个容器的 yt-dlp 配置追加 provider 地址（三个 podsync 在默认 bridge，用网关 172.17.0.1 连宿主机发布的端口）：--extractor-args "youtubepot-bgutilhttp:base_url=http://172.17.0.1:4416"重启后验证，三个容器一致变为：PO Token Providers: bgutil:http-1.3.1 (external)（另外两行 script-node/script-deno 显示 unavailable 是正常的——我们只部署了 http 模式，官方也会优先采用它。）一个持久化的坑：pip 装的 plugin 和这行 config 都在容器内、不在挂载卷，容器一旦重建就没了。所以要把"升级 libstdc++ + 恢复 node + 写 ejs/bgutil 配置 + 装 plugin"整套固化进重建初始化脚本，否则每次重建都要重来。五、验证A01 崩溃已根治最新几轮更新完整跑通：updating A01 → running cleaner → creating A01.xml → next update，不再有 bad format string。那几条"XX分钟"预告被 fqx 的空时长兜底直接处理掉，没进下载队列，结果一样干净。PO Token 链路真实生效三个容器 PO Token Providers: bgutil:http-1.3.1 (external) 全部 available；debug 也确认 plugin 目录已被识别（Plugin directories: /usr/lib/python3.10/site-packages/yt_dlp_plugins）。一个关于 429 的假警报手动测试时撞到过 HTTP Error 429: Too Many Requests——一度以为要上 WARP 换出口。但复盘发现：这是我拿同一个视频短时间连点七八次逼出来的限流，不是日常表现。翻三个 feed 的实际运行日志，没有任何一条 429 / Sign in to confirm / bot 报错，还能看到真实的 successfully downloaded file。所以日常低频（3 小时周期）根本不触发，WARP 这药不用吃——何况 WARP 出口是 Cloudflare 共享脏段，对 YouTube 反而更容易招 bot 检测，多半帮倒忙。（顺带在 c 日志里发现一个与本次无关的 playlistNotFound (404)，是某个源频道被删/改 ID 了，另行清理。）六、复盘：几条能带走的经验报错措辞是最好的定位线索。 bad format string + 空值，直接指向 ISO8601 解析，进而锁定"这是 API 列表阶段、不是下载阶段"，一句话把问题从 yt-dlp 挪到了 podsync 本体。搞清每个参数作用在哪个阶段。 youtube_dl_args 只管下载；列表构建走 YouTube Data API。作用阶段错位，是我第一次尝试失败的根因。docker :latest 不会自动刷新运行中的容器。 yt-dlp 靠 self_update 常新，会掩盖底层 Go 二进制其实很老的事实。二者要分开看。别跨代基础镜像移植二进制。 no such file or directory 很多时候是"缺库"的伪装。与其把新二进制塞进老环境，不如直接用新环境当基底。框架 ≠ 能力。 GetPOT 加载了不代表 PO Token 在生成，真正的判据是 PO Token Providers 里有没有 available 的 provider。区分"测试触发"和"生产表现"。 手动高频探测撞出的 429，不能当成日常问题来治。先确诊真实日志，再决定要不要动出口——很多时候最好的处理是"什么都不做"。最终状态：三个容器都在 fqx 镜像上、yt-dlp 2026.07.04、A01 不再崩、下载有 bgutil PO Token 兜底——比出问题之前更稳了一档。（记：在处理这类问题的时候明显感觉claude的能力强于chatgpt。）
最近我的私有 VPS 被部署恶意程序的事件，对我最大的触动其实不是如何清除病毒，而是意识到另一个经常被忽略的问题——数据安全。以前更多关注的是服务器安全：关闭端口、最小权限、防火墙、监控告警……这些都很重要。但真正静下心来思考后发现，即使安全措施做得再完善，也没有任何人敢保证服务器永远不会出现问题。真正能够降低风险的，不仅是防御，更是可恢复能力（Recoverability）。因此，在完成 Sentinel 安全监控体系之后，我又开始补齐另一块短板——应用数据备份体系。为什么要做备份对于 我的 网站来说，真正有价值的并不是代码。代码可以通过 GitHub 恢复，可以重新部署；真正难以恢复的是：SQLite 数据库中的业务数据配置文件网站运行过程中不断积累的数据如果服务器被删除、磁盘损坏，甚至再次遭遇攻击，没有备份意味着这些数据可能永久丢失。所以这次工作的目标很明确：让任何一台服务器出现问题，都能够在最短时间内恢复业务。第一层：服务器本地自动备份首先为项目新增了独立备份脚本。备份策略分为两类：数据备份每天自动执行使用 SQLite Backup API 导出数据库避免直接复制正在运行中的 SQLite 文件自动校验数据库完整性（PRAGMA integrity_check = ok）默认保留 60 天历史版本代码备份每周自动打包自动排除 .git、.venv、缓存目录、运行数据、备份目录等无关内容默认保留 12 周历史版本这样，每一次备份都会生成带时间戳的压缩包，可以随时回滚到任意历史版本。第二层：Systemd 定时自动执行备份不能依赖手工执行。因此，又新增了 systemd 定时任务，让整个流程完全自动化。目前的计划为：数据备份：每天执行代码备份：每周执行所有 Timer 已安装到系统，并验证能够正常触发。以后服务器即使长期无人登录，也会持续生成新的备份。第三层：NAS 异地备份本地备份解决不了服务器本身损坏的问题。如果 VPS 被删除、硬盘故障，或者整台机器不可恢复，本地所有备份仍然会一起丢失。因此又增加了异地备份。整个方案采用 NAS 主动拉取（Pull）的方式：VPS │ SSH + rsync │ Synology NAS（Docker）NAS 通过 Docker 容器定时连接 VPS，将服务器上的备份目录同步到本地。同步完成后，备份会保存在 NAS 独立的数据分区中，不占用系统分区空间，也方便后续继续扩展更多项目。这样即使 VPS 整体损坏，也仍然保留着完整的数据和代码备份。第四层：恢复文档备份真正发挥作用，是在恢复的时候。因此，又补充整理了完整的备份文档，包括：本地备份方式自动备份部署方法NAS Docker 配置数据恢复流程代码恢复流程同时同步更新了项目状态文档和 GitHub 配置，使整个备份体系能够长期维护，而不是依赖记忆。
一次 VPS 安全事件后的反思：从问题发现到建立完整监控体系
最近这几天，几乎把所有业余时间都投入到了 VPS 安全排查中。事情的起因，是一台 Oracle Cloud VPS 被发现运行了疑似恶意程序。虽然最终没有造成明显损失，但整个事件却让我意识到一个问题：真正危险的不是服务器被攻击，而是服务器被攻击了很久，而自己却毫不知情。这次事件，也促使我重新设计了整个 VPS 安全监控体系。从最初的发现异常，到紧急处置，再到监控体系建设和持续能力升级，对自己的运维理念产生了很大的影响。一、问题的发现：一次持续多天的安全隐患最初收到的并不是入侵告警，而是一条来自自己编写的巡检脚本的提示：可疑高 CPU 进程。登录服务器后，很快发现了异常。进程运行在 /tmp 目录，文件名随机，长期占用大量 CPU，父进程指向 LiteLLM，随后又发现多个 Python Loader 以及已经退出的僵尸进程。继续分析时，所有程序已经全部删除，只留下运行痕迹。整个行为非常符合目前 Linux 挖矿木马常见的模式：下载恶意程序临时运行删除自身不留下文件虽然最终没有找到矿池地址和程序样本，无法做到百分之百确认，但综合各种行为特征来看，基本可以判断是一类典型的 CPU 挖矿木马。更让我后怕的是：从日志推算，这个程序已经运行了接近二十天。如果不是偶然触发巡检，我甚至不知道服务器曾经执行过恶意程序。二、紧急处置：先控制风险，再寻找原因确认异常后，并没有急于寻找攻击入口，而是先进行了风险控制。首先检查了系统是否已经被进一步控制：SSH 登录记录authorized_keysCron 定时任务Systemd 服务网络连接新监听端口Docker 容器临时目录幸运的是，没有发现持久化后门，也没有发现异常网络连接。随后又检查了 LiteLLM 保存的各种 API Key。由于服务器已经运行过未知程序，虽然没有发现盗刷迹象，但为了避免风险扩大，还是全部进行了轮换，包括：Gemini API KeyLiteLLM Master Key与此同时，也重新评估了 Oracle Cloud Free Tier 的风险。结合官方 Abuse 策略来看，只要没有持续挖矿、DDoS、垃圾邮件等行为，被封禁账号的概率较低。最后还是决定：这台服务器不再继续使用，重装恢复可信环境。对于安全来说，“怀疑已经失陷”和”已经失陷”并没有本质区别。三、重新认识监控：没有监控，就没有安全整个排查过程中，一个问题不断出现：很多异常，其实已经发生了。但是没有任何证据留下。例如：/tmp 程序已经删除Python Loader 已删除临时端口已经关闭高 CPU 已恢复正常等人登录服务器的时候，现场已经消失。这让我意识到：传统的”发现问题再登录服务器分析”已经越来越难应对现在的恶意程序。因此，我开始重新设计整个监控体系。四、构建多层监控体系目前已经初步形成四层监控架构。第一层：Healthchecks负责监控所有定时任务是否仍然正常执行。任何一个定时任务停止运行，都能够第一时间收到通知。第二层：Sentinel 自检每台服务器都会定时向 Telegram 发送自检消息。例如：✅ Sentinel 自检：Telegram 通道正常如果某台服务器突然没有发送自检信息，即使 Healthchecks 正常，也说明监控程序自身可能已经失效。这次排查过程中，就发现了三台服务器没有发送 Sentinel 自检消息。虽然 VPS 本身正常，Healthchecks 也正常，但最终定位到 Sentinel 部署存在遗漏。这也验证了：监控程序本身，也需要被监控。第三层：安全巡检目前已经能够自动检测：新监听端口高 CPU 可疑进程SSH 登录变化Root 登录新增用户Systemd 服务变化Cron 变化Docker 异常API Key 风险临时目录可执行文件所有异常统一推送 Telegram。第四层：人工复核自动监控负责第一时间发现问题。真正的判断仍然需要人工分析。因为：安全最大的敌人，不一定是漏报。还有误报。五、监控上线后的第一次”考试”监控刚上线，很快连续收到几条安全告警。例如：发现新的 UDP 监听端口。第一时间登录服务器检查。结果发现：只是 WireGuard、rpcbind 等系统组件。还有一次：可疑高 CPU。最后发现：居然是巡检脚本自己执行 ps 命令时，把自己识别成了高 CPU 进程。这些误报虽然没有真正的安全风险，却让我认识到：监控不仅要能够发现异常，更要尽可能减少误报。否则时间久了，真正的重要告警也容易被忽略。六、监控能力持续升级结合这几次排查，目前已经规划了 Sentinel 下一阶段升级方向。包括：新端口二次确认，过滤瞬时监听进程白名单机制自动保存现场证据SHA256 校验可执行文件 Hash 监控Docker 配置变化检测Caddy、Xray 配置变更监控API Key 泄漏检测服务器在线数量自动统计Healthchecks 与服务器清单自动一致性校验未来收到告警时，希望不仅知道：“发现异常。”而是能够直接看到：哪个进程谁启动的来自哪里是否属于白名单风险等级是否保留现场证据真正做到收到告警即可判断风险，而不是再登录服务器开始排查。七、最大的收获这次事件最大的收获，并不是找到了某个漏洞，也不是确认了攻击入口。而是重新理解了安全运维。以前认为：安全，就是系统没有被攻击。现在更认同：安全，是能够及时发现攻击，并快速恢复。对于个人维护多台 VPS 来说，不可能做到绝对安全。真正能做的，是建立一套持续运行的监控体系：能够发现异常；能够验证监控自身是否正常；能够尽可能保留现场；能够不断降低误报；能够在最短时间完成处置。安全不是安装几个工具，也不是一次性的加固，而是一项持续演进的工程。这次疑似木马事件，更像是一堂昂贵但值得的实践课。它让我从”出了问题再排查”的思维，逐步转变为”持续监测、快速发现、及时响应”的运维理念。未来，我也会继续完善 Sentinel，将其打造成适合个人多 VPS 管理的一套轻量级安全监控平台，让服务器真正实现可观测、可追踪、可告警、可恢复。
前言多年来，我一直将 Dropbox 作为最重要的资料管理和同步平台。工作资料、学习笔记、家庭文档以及各种项目文件，几乎都依赖 Dropbox 在不同设备之间同步。这个周末，我刚刚完成了一项重要工作——利用 Immich 替代 Google Photos，实现了照片的统一管理和自托管存储。当照片完成迁移之后，一个新的问题自然摆在面前：既然照片已经回到了自己的 NAS，那么文件是否也应该回到自己的 NAS？于是我开始寻找 Dropbox 的替代方案。经过一段时间调研和测试，最终选择了 Seafile。而更有意思的是，在使用过程中，我发现 Seafile 不仅仅可以替代 Dropbox 的同步功能，还可以与 AI 能力结合，逐步构建属于自己的知识库体系。为什么选择 Seafile在 NAS 生态中，可选的文件同步方案很多：Synology DriveNextcloudOwnCloudResilio SyncSeafile最终选择 Seafile，主要基于三个考虑。同步能力优秀Dropbox 最大的优势一直都是同步。而 Seafile 是少数能够在同步体验上接近 Dropbox 的开源产品。它采用块级同步技术：文件仅上传变化部分大文件同步效率高网络带宽利用率更好多设备同步稳定实际使用下来，同步体验完全达到预期。数据掌握在自己手中Dropbox 本质上仍然是 SaaS 服务。虽然方便，但数据最终存放在第三方平台。而 Seafile 部署在自己的 NAS 上：数据自主掌控不受订阅空间限制可以结合 NAS 实现多重备份不受平台政策变化影响这一点与我选择 Immich 替代 Google Photos 的逻辑完全一致。资源占用较低我的设备是一台：群晖 DS218+（10GB内存）相比 Nextcloud 这种“大而全”的平台，Seafile 更专注于文件同步本身。因此：部署简单资源占用低长期运行稳定对于老款 NAS 非常友好。从文件同步到知识管理使用一段时间后，我发现一个问题：Seafile 可以很好地解决文件同步问题。但它并没有解决另一个问题：如何快速找到文件中的知识？随着资料越来越多：项目方案会议纪要学习笔记技术文档博客素材虽然都保存在 Seafile 中，但真正需要某个内容时，依然需要：回忆文件名回忆目录结构手工搜索而近年来 Dropbox 已经逐渐向智能内容平台发展。例如：AI 搜索内容摘要智能问答文档关联这些能力恰恰是 Seafile 社区版所欠缺的。于是我开始思考：能否让 Seafile 专注存储，而把 AI 能力外挂出来？最终形成了下面的架构。Seafile + Open WebUI + RAG整体架构如下：Seafile │ ▼ Seaf-CLI 实时同步 │ ▼ NAS 本地目录 │ ▼ oikb 自动同步 │ ▼ Open WebUI Knowledge │ ▼ LiteLLM │ ├── Gemini Flash（问答） └── Gemini Embedding（向量化）整体思路非常简单：第一层：Seafile负责：文件存储文件同步版本管理这一层不承担任何 AI 功能。第二层：Seaf-CLI这是整个方案最关键的一步。Open WebUI 并不能直接读取 Seafile。因此需要利用官方客户端：seaf-cli将 Seafile 库实时同步到 NAS 本地目录。同步后的目录就是普通文件夹：/work /home /study /private所有文件变化都会自动同步下来。第三层：Open WebUIOpen WebUI 负责：文档解析向量存储RAG 检索AI 对话这里我没有使用本地模型。而是接入自己已经部署好的 LiteLLM 网关。第四层：LiteLLMLiteLLM 统一管理各种模型。当前配置：问答模型gemini-2.5-flash负责最终生成回答。向量模型gemini-embedding-001负责：文档向量化问题向量化相似度检索这是构建知识库必不可少的一层。自动增量同步知识库如果每次新增文件都手工上传知识库，这套体系就失去了意义。因此我引入了：oikb（Open WebUI Knowledge Base Sync）它负责：自动扫描目录新文件自动入库修改文件自动更新删除文件自动清理定时增量同步最终形成完整闭环：文件放入 Seafile │ ▼ 自动同步到 NAS │ ▼ 自动更新 Open WebUI │ ▼ 自动生成向量 │ ▼ 知识库实时更新整个过程无需人工干预。实际效果部署完成后，最大的变化是：我不再依赖目录结构寻找资料。而是直接向自己的知识库提问。例如：工作场景总结一下近三个月客服智能体建设进展生活场景我之前关于 Immich 迁移记录了哪些内容当前感受截至目前，Seafile 已经基本满足了我对 Dropbox 的同步需求。但如果只看同步能力，它仍然只是一个文件管理工具。真正让我看到价值的，是它与 AI 的结合。我越来越认同一个思路：存储和智能应该解耦。Seafile 负责保存数据。Open WebUI 负责理解数据。LiteLLM 负责连接模型。这样每一层都可以独立演进。未来即使：Seafile 换成其他同步平台；Gemini 换成 Claude；Open WebUI 换成新的 RAG 系统；整体架构依然成立。写在最后从 Google Photos 迁移到 Immich，再从 Dropbox 迁移到 Seafile，我逐渐完成了个人数据体系的重要两步：照片回归自己掌控文件回归自己掌控而现在正在进行第三步：让这些数据真正具备知识价值。目前 Seafile 仍处于试运行阶段，我还没有最终决定是否完全替代 Dropbox。因为在文档智能化管理方面，两者仍然存在一定差距。不过随着 Open WebUI、RAG 和大模型能力的不断完善，我越来越期待这样一种未来：文件只是存储在那里，而知识能够主动被发现、被检索、被利用。当有一天我不再需要记住文件放在哪里，而只需要向自己的数据提问时，也许这套体系才算真正成熟。
从 Google Photos、iCloud 到 Immich：构建属于自己的照片管理中心
前言照片记录着生活中的点点滴滴。过去十几年里，我一直习惯使用 Google Photos 作为主要的照片存储和管理平台。Google Photos 刚推出时，可以说是最好的照片管理工具之一。不限量存储、强大的搜索能力、优秀的人脸识别和时间轴管理，让我几乎把所有照片都上传到了 Google Photos。然而随着 Google 取消免费无限容量政策，照片存储逐渐变成了一项持续支出。随着照片和视频数量不断增加，每个月都需要为额外空间支付费用。同时，由于网络环境原因，Google Photos 的访问体验也越来越不方便。更大的问题在于，随着设备的增加，我的照片开始散落在多个平台。Google Photos：主要照片库iCloud Photos：部分 iPhone 照片华为手机本地照片DJI 无人机照片和视频运动相机视频NAS 本地存储由于不同设备和平台之间的同步机制不同，同一张照片经常会出现在多个地方。结果就是：照片越来越多，但真正想找到一张照片却越来越困难。因此，我决定借助 Immich 构建一个统一的照片管理中心，实现所有照片的集中管理和自动去重。为什么选择 Immich在选择照片管理平台时，我对比过多个方案：Synology PhotosPhotoPrismNextcloud PhotosImmich最终选择 Immich，原因主要有三个。第一，体验最接近 Google PhotosImmich 提供了：时间轴浏览地图浏览人脸识别智能搜索手机自动备份多用户管理对于 NAS 用户来说，已经具备替代 Google Photos 的能力。第二，完全掌握数据所有照片存储在自己的 NAS 中。不再依赖第三方平台，也不再担心未来政策变化导致额外费用增加。第三，强大的排重能力这也是我此次迁移最看重的一点。我的照片来源复杂：Google PhotosiCloud华为手机DJI 设备本地目录这些来源之间存在大量重复照片。Immich 本身具备较好的重复照片识别能力，而 immich-go 工具又进一步增强了导入过程中的排重能力。这意味着：无论照片来自哪里，最终在 Immich 中都只保留一份。这正是我想要的结果。第一步：升级 Immich开始迁移之前，我首先对 Immich 进行了升级。我的版本已经比较老：v1.135.3升级目标版本：v2.7.5由于跨越多个大版本，因此先进行了完整备份：PostgreSQL 数据库Immich Librarydocker-compose 配置.env 配置确认备份完成后执行升级。升级完成后重新索引数据库和媒体文件，为后续的大规模导入做好准备。第二步：导入 Google PhotosGoogle Photos 是我的核心照片库。经过多年积累，导出的 Google Takeout 数据达到数百 GB。Google Takeout 导出通过 Google Takeout 导出 Google Photos：Google Takeout ↓ Google Photos ↓ ZIP格式 ↓ 10GB分卷最终生成了数十个压缩包。批量下载问题由于文件数量巨大，手工逐个下载非常麻烦。因此采用浏览器 Cookie 认证的方式编写自动下载脚本。脚本实现：自动读取Cookie ↓ 自动下载压缩包 ↓ 失败自动重试 ↓ 断点续传最终实现无人值守下载。使用 immich-go 导入Google Photos 最大的问题是元数据。很多拍摄时间、相册信息并不在照片文件本身，而是在对应的 JSON 文件中。因此不能简单解压后直接上传。最终采用：immich-go upload from-google-photos进行导入。导入过程中：自动恢复拍摄时间自动恢复相册自动关联元数据自动检测重复照片整个过程虽然耗时较长，但最终成功保留了 Google Photos 的完整历史记录。第三步：导入 iCloud Photos相比 Google Photos，iCloud 的导出过程反而更加折腾。Apple 数据导出Apple 提供数据导出功能。申请后会生成下载链接。但这里有一个问题：下载链接有效时间非常短实际测试大约十几分钟后就会失效。暴力并发下载面对这个限制，我采用了最直接的方法：获取所有下载链接 ↓ 同时启动全部下载任务 ↓ 最大化利用有效时间虽然简单粗暴，但效果很好。最终成功获取全部照片和视频。使用 immich-go 导入导入方式与普通目录类似：immich-go upload导入过程中：自动读取 EXIF自动识别拍摄时间自动排重大量已经存在于 Google Photos 中的照片被自动识别为重复资产。因此最终并没有出现照片数量翻倍的问题。第四步：导入 DJI、运动相机和无人机素材除了手机照片之外，我还有大量设备产生的视频文件。包括：DJI 无人机DJI ActionGoPro运动相机其他摄影设备这些文件大多数以目录形式保存在 NAS 中。例如：DJI ├── 2023 ├── 2024 └── 2025 Action ├── 骑行 ├── 旅游 └── 日常这部分数据没有复杂元数据，因此处理相对简单。直接通过 immich-go 导入即可：immich-go upload /path/to/media工具会自动递归扫描所有目录。同时保留：视频拍摄时间文件元数据目录结构信息immich-go：整个迁移过程的核心工具整个迁移过程中，真正发挥关键作用的是 immich-go。虽然数据来源完全不同：Google PhotosiCloud Photos本地目录DJI 视频但最终都可以通过调整参数的方式完成导入。例如：Google Photos → from-google-photos iCloud → 普通目录导入 本地媒体库 → upload统一导入到 Immich。这大大降低了迁移复杂度。最终效果经过数天整理和导入后，我最终完成了照片库整合。数据来源：Google Photos + iCloud Photos + 华为手机照片 + DJI照片视频 + 运动相机素材统一汇聚到：Immich借助 Immich 的排重机制：重复照片自动识别重复视频自动过滤仅保留唯一副本最终形成了一套统一、干净、可持续维护的照片库。Immich 的遗憾：DS218+ 已经有些力不从心虽然整个迁移过程最终顺利完成，但在实际使用过程中，我也发现了一个新的问题：Immich 越来越强大，而我的 DS218+ 已经有些跟不上了。我的 NAS 是群晖 DS218+，这是一台非常经典的机型。配置为：Intel Celeron J3355 2核心 最大6GB内存在运行 Synology Photos 的时代，这样的配置基本没有压力。但 Immich 近年来发展非常迅速，尤其是引入了：人脸识别智能搜索CLIP 向量检索视频缩略图生成地图索引机器学习模型这些能力背后都需要大量 CPU 和内存资源。在导入数万张照片和数百 GB 视频的过程中，我明显感觉到 DS218+ 已经接近极限。经常可以看到：immich-machine-learning ffmpeg 缩略图生成任务长期占用较高 CPU。有时候即使暂停任务队列，后台仍然需要较长时间完成已经提交的索引任务。特别是在：人脸重新识别视频转码大规模导入机器学习模型更新这些场景下，DS218+ 的响应速度明显下降。甚至会影响 NAS 上其它应用的正常运行。对于照片浏览来说问题并不大，但如果希望充分发挥 Immich 最新版本的全部能力，那么硬件已经开始成为瓶颈。下一步计划目前 Immich 已经成为我的唯一照片管理平台，因此后续的重点不再是迁移，而是基础设施升级。未来计划将 NAS 升级到性能更强的平台，例如：DS923+DS1522+自组装 NASMini PC + Docker + Immich让 Immich 的 AI 能力得到充分释放。对于目前的 DS218+，我认为仍然能够胜任：照片存储 日常浏览 手机自动备份但如果照片数量达到数万张以上，并且开启全部 AI 功能，那么升级硬件已经只是时间问题。后记回过头来看，这次迁移最大的收获并不是节省了 Google Photos 的订阅费用，也不仅仅是将照片从多个平台汇聚到一个系统。更重要的是，我终于拥有了一套完全属于自己的照片管理体系。从 Google Photos 的免费时代开始，到后来订阅扩容，再到今天完成向 Immich 的迁移，照片存储经历了从“依赖云服务”到“掌控数据”的转变。如今，Google Photos、iCloud、华为手机、DJI 无人机以及各种运动相机产生的照片和视频，都已经统一汇聚到 Immich 中，并通过自动排重实现唯一存储。未来即使更换设备、更换云服务，甚至更换 NAS，照片库本身都不会受到影响。而对于一个已经积累了十几年照片和视频的人来说，这种确定性和掌控感，或许比任何功能都更加重要。
一个面向 NAS 的影视文件自动整理工具
最近整理 NAS 上的电影和剧集文件时，遇到一个很实际的问题：下载目录里的文件名经常非常混乱，包含资源站水印、清晰度、压制组、音轨、字幕信息，甚至还有拼音缩写、别名、错别字。如果全部手工整理，不仅耗时，也很容易出错。伴随Ai编码的使用，我现在已经比较少的使用现有的代码了，主要还是为了避免一些安全上的问题，毕竟要部署在我的装着各种资料的nas上。于是做了一个小工具：AutoReel。它的目标很简单： 监听一个输入目录，自动识别电影和剧集，然后按 Emby / Jellyfin / Plex 友好的目录结构移动并重命名文件。项目地址：https://github.com/walker22026/AutoReel代码的编写过程远远的超出的我的开始预估，框架搭建起来容易，但是真正的运行，能用就有一些问题，当然更谈不上好用。最主要的问题就是我的节目源主要来自于阿里云或者百度云，这个共享资源为了避免被“和谐”采取各种名称变型，导致猜测也猜不出来，这种情况下，我尝试使用llm来进行节目真实名称的理解，效果并不好。最终还是选择简化处理的模式，能够自动处理的自动处理，无法识别的放入特定目录中，等待使用人接入。避免llm自动处理导致的节目的整理混乱。项目定位AutoReel 不是一个复杂的媒体库管理系统，也不替代 Emby、Jellyfin、Plex。它只做一件事：把下载目录里的影视文件，整理到标准媒体库目录中。例如：输入目录： /emby/source/毒液：最后·一舞.2160p.DV.HDR (2024)/xxx.mp4 整理后： /emby/电影/毒液：最后一舞 (2024)/毒液：最后一舞 (2024).mp4剧集则整理为：/emby/剧集/低智商犯罪 (2026)/Season 01/低智商犯罪 - S01E01.mp4当前执行策略AutoReel 启动后会监听输入目录。当前设计中有三个核心目录：输入目录：/host/emby/source 电影目录：/host/emby/电影 剧集目录：/host/emby/剧集输入目录下还会自动生成：_unrecognized 未识别目录 _duplicates 重复文件目录 _pending_delete 无媒体文件待清理目录整体流程是：输入目录 ├── 单个视频文件 │ ├── 识别成功 -> 移动到电影/剧集目录 │ └── 识别失败 -> 移入 _unrecognized │ ├── 子目录 │ ├── 作为一个批次处理 │ ├── 全部视频识别成功 -> 整个目录整理 │ ├── 任一视频识别失败 -> 整个目录移入 _unrecognized │ └── 目标文件已存在 -> 整个目录移入 _duplicates │ └── _unrecognized / _pending_delete └── 扫描时跳过为什么不用硬链接最初考虑过硬链接，这样可以让下载目录和媒体库目录同时存在一份“看起来独立”的文件，但实际只占一份空间。不过最终我选择了直接 move 模式，也就是：直接移动并重命名原始文件。原因是我的使用场景更偏向媒体库整理，不需要继续做种，也不希望一个文件在多个位置看起来同时存在。这样逻辑更简单，也更容易理解：整理完成后，源目录里就不再保留原文件。电影处理规则电影分两种情况。单视频目录如果一个目录中只有一个视频文件，优先使用目录名识别电影。例如：毒液：最后·一舞.2160p.DV.HDR (2024)/ xxx.mp4清洗后会识别为：毒液：最后·一舞 2024然后通过 TMDB 查询，整理为：电影/毒液：最后一舞 (2024)/毒液：最后一舞 (2024).mp4多视频目录如果一个目录下有多个视频文件，则更像电影合集或系列电影。例如：夺宝奇兵/ Raiders.of.the.Lost.Ark.1981.mkv Indiana.Jones.and.the.Last.Crusade.1989.mkv Indiana.Jones.and.the.Dial.of.Destiny.2023.mkv这种情况下不会只用目录名“夺宝奇兵”去匹配一部电影，而是逐个视频文件识别。只有全部识别成功后，才会整体移动。如果其中任何一个文件无法识别，整个目录会被移动到 _unrecognized，避免整理一半导致目录结构被破坏。剧集处理规则剧集采用“目录优先”的原则。原因是很多剧集文件名非常简单，例如：低智商犯罪（2026）/ 01 4K.mp4 02 4K.mp4 03 4K.mp4如果直接把 01 4K.mp4 拿去 TMDB 搜索，很可能会误识别成电影，比如：01 -> 某部电影 02 -> 另一部电影 03 -> 又一部电影这是非常危险的。所以现在的策略是：目录名负责识别剧名 文件名只负责提取集数也就是说：低智商犯罪（2026）用于识别剧集本身。而：01 4K.mp4 02 4K.mp4只会被解释为第 1 集、第 2 集，不会再拿去搜索电影。整理后：剧集/低智商犯罪 (2026)/Season 01/低智商犯罪 - S01E01.mp4 剧集/低智商犯罪 (2026)/Season 01/低智商犯罪 - S01E02.mp4如果无法从文件名中提取集数，整个目录会进入 _unrecognized，避免错误重命名。文件名清洗国内资源站的文件名经常包含大量无效信息，例如：凶器.6v电影 地址发布页 www.6v123.net 收藏不迷路 双子杀手 4K原盘REMUX 杜比视界 国英双音 内封字幕 穿条纹睡衣的男孩.The Boy in the Striped Pajamas.2008.BluRay.1080P.H265.10bit.英语.中英特效字幕AutoReel 会先做本地清洗，去掉常见水印和技术标签：6v电影 地址发布页 收藏不迷路 www.xxx.com 4K REMUX 杜比视界 国英双音 内封字幕 1080P BluRay H265清洗后的结果更适合提交给 TMDB 查询。例如：双子杀手 4K原盘REMUX 杜比视界 国英双音 内封字幕会清洗为：双子杀手年份匹配策略年份是防止误识别的重要依据。例如如果文件名里有：某电影 (2019)那么 TMDB 返回的结果必须是：2019或者允许很小的容差：2018 / 2020如果搜索结果只有 2000 年的同名电影，就不会匹配成功。这样可以避免“同名不同年份”的电影被错误整理。别名机制有些电影或剧集的中文名、英文名、民间译名并不一致。AutoReel 支持别名表：/config/aliases.json示例：{ "毒液：最后·一舞": "Venom: The Last Dance", "芭蕾杀姬": "Ballerina", "特 工 绍 特": "Salt" }别名表优先级高于普通搜索。 这对于 TMDB 搜不到、中文名不标准、或者文件名被严重缩写的情况很有用。为什么取消 LLM项目中曾经尝试加入 LLM 作为兜底识别方式，希望通过大模型推断文件名。但实际测试后发现效果并不稳定。原因是：影视文件名高度依赖 TMDB 数据。很多冷门片、译名、资源站缩写并不在 LLM 的可靠知识范围内。LLM 可能“猜得很像”，但并不一定准确。对于自动重命名文件来说，错误识别的代价比识别失败更高。所以最后取消了 LLM。当前原则是：宁可识别失败进入 _unrecognized，也不要自信地改错文件名。这也是整个项目后期优化的核心原则。重复文件处理如果目标文件已经存在，AutoReel 不会覆盖。例如目标目录中已经有：电影/乱战 (2005)/乱战 (2005).mkv新的输入目录又识别到了同一个目标路径，那么整个目录会被移动到：source/_unrecognized/_duplicates/并写入原因文件：目录中xxx目标文件已存在.txt这样可以人工判断：是重复下载是不同版本是需要保留还是可以删除未识别目录的人工处理识别失败的文件或目录会进入：source/_unrecognized目录中会生成原因文件，例如：目录中xxx文件未识别到.txt 目录xxx无法识别.txt人工处理方式很简单：进入 _unrecognized修改文件名或目录名把它移回输入目录AutoReel 会重新扫描并处理这样保留了人工接入能力，也避免程序盲目处理。群晖 NAS 的特殊处理在群晖上，目录中可能会出现：@eaDir这是 Synology 的扩展属性目录。AutoReel 会跳过这些目录，避免把它们作为附属文件移动到媒体库中。同时也会跳过：.DS_Store __MACOSXDocker 部署项目使用 Docker 部署。示例 docker-compose.yml：services: media-organizer: build: . container_name: AutoReel restart: unless-stopped environment: WATCH_DIR: /host/emby/source MOVIE_DIR: /host/emby/电影 TV_DIR: /host/emby/剧集 UNRECOGNIZED_DIR_NAME: _unrecognized DUPLICATE_DIR_NAME: _duplicates PENDING_DELETE_DIR_NAME: _pending_delete TMDB_API_KEY: "你的 TMDB v3 API Key" TMDB_LANG: zh-CN FILE_ACTION: move DRY_RUN: "false" SCAN_ON_START: "true" QUIET_SECONDS: "10" MIN_FILE_SIZE_MB: "100" LOG_LEVEL: INFO volumes: - /volumeUSB1/usbshare/emby:/host/emby - ./config:/config启动：docker compose up -d --build查看日志：docker compose logs -f停止：docker compose down我的目录配置我的 NAS 目录结构是：输入目录： /volumeUSB1/usbshare/emby/source 电影目录： /volumeUSB1/usbshare/emby/电影 剧集目录： /volumeUSB1/usbshare/emby/剧集Docker 中挂载为：volumes: - /volumeUSB1/usbshare/emby:/host/emby容器内配置为：WATCH_DIR: /host/emby/source MOVIE_DIR: /host/emby/电影 TV_DIR: /host/emby/剧集Dry Run 的作用首次运行建议开启：DRY_RUN: "true"这时 AutoReel 只会打印计划，不会真正移动文件。日志中会看到：[DRY-RUN][move] source/xxx.mkv -> 电影/xxx/xxx.mkv确认识别结果没问题后，再改为：DRY_RUN: "false"当前已知边界AutoReel 当前仍然是一个轻量工具，不追求覆盖所有媒体管理场景。目前已知边界包括：依赖 TMDB，NAS 必须能访问 api.themoviedb.org冷门片、译名混乱、缩写严重时仍可能需要别名表多版本电影、导演剪辑版、加长版暂时没有单独版本管理原盘目录、ISO、BDMV 结构还没有完整特殊处理USB 移动硬盘作为输入目录时，监听机制可能影响硬盘休眠，需要结合 NAS 实测项目原则这个项目一路调整下来，最终形成了几个原则：自动化不能以错误整理为代价。文件名越混乱，越应该保守处理。识别失败可以人工修正，识别错误反而更麻烦。剧集目录不能用单集文件名去搜电影。对 NAS 用户来说，Docker 部署和低配置门槛很重要。配置尽量集中在 docker-compose.yml，不再依赖 Web 页面或额外配置文件。总结AutoReel 解决的是一个很小但很实际的问题：下载目录很乱，媒体库需要干净。它不会替代完整的媒体库系统，但可以作为下载目录到媒体库目录之间的一层自动整理工具。目前它已经可以处理：电影自动识别剧集批量整理文件名水印清洗TMDB 匹配年份校验别名映射重复文件隔离未识别文件人工接入Docker 部署群晖特殊目录跳过后续可以继续优化的方向：更完善的电影版本识别更强的原盘目录支持更友好的未识别清单更低唤醒的移动硬盘监听模式更完善的别名维护方式