家庭影音直链播放系统搭建:alist+Emby+Docker实战
1. 这不是“私有云”,是家庭数字资产的自主播放中枢
很多人一看到“家里云”三个字,第一反应就是搭个NAS、装个群晖套件、开个SMB共享——结果呢?手机点开视频要等30秒转码,电视端播4K片源卡成PPT,孩子想看的动画片得先下载到平板再传过去。这哪是“云”,这是“晕”。我折腾了三年多,从最早用树莓派硬解H265失败,到后来在旧笔记本上跑Docker被风扇吵得睡不着,最后才明白:家庭影音服务的核心矛盾,从来不是存储容量或带宽大小,而是“内容路径”的可控性与确定性。
所谓“直链播放”,说白了就是让Emby服务器不经过任何中间转码、不走代理缓存、不调用第三方CDN,直接把原始文件地址(比如你存在115网盘里的MKV链接、阿里云OSS里的MP4地址、甚至本地NAS上某个目录的HTTP可访问路径)原封不动地推给播放器。播放器拿到的就是真实URL,它自己决定要不要缓冲、要不要硬解、要不要跳过片头——这才是真正的“自主权”。而alist,就是这个链条里最关键的“地址翻译官”:它不存文件,只管把各种云盘、对象存储、本地路径,统一翻译成标准HTTP链接;Emby再通过插件或反向代理,把这些链接识别为“本地媒体”,从而绕过所有转码逻辑。
关键词里反复出现的docker-compose,不是为了装逼,而是因为这套组合对家庭环境极其友好——它把Emby、alist、nginx反向代理、甚至MySQL(如果需要元数据持久化)全部打包成一个可复现的配置文件。你换台机器,只要装好Docker,docker-compose up -d一条命令,整个系统就活了。不像宝塔面板那种图形化方案,看着简单,出问题时日志散落在七八个地方,排查起来像大海捞针。我见过太多人卡在“docker-compose up 报错 unable to get image 'mysql:8.0.34'”这一步,其实根本不是镜像拉不到,而是Docker daemon没启动、或/etc/docker/daemon.json里配置了国内镜像源但网络不通、又或者docker-compose.yml里写的镜像标签压根不存在(比如写成了mysql:8.0.34,但Docker Hub上实际只有mysql:8.0或mysql:8.0.33)。这些细节,恰恰是“能跑通”和“能长期稳住”的分水岭。
所以这篇内容,不讲怎么注册115账号、不教Emby Premiere订阅怎么买、更不会告诉你“alist可以win7吗”——Win7早已停止支持,连Docker Desktop都不兼容,强行折腾只会掉进驱动冲突、WSL版本错配、Hyper-V无法启用的三重坑。我们要解决的是:当你已经有一台能跑Linux的旧电脑、一个115会员、一份存了上千部电影的资源库,如何用最轻量、最可控、最不易崩的方式,让客厅电视、孩子iPad、你自己的MacBook,点开就能播,且画质声音完全不打折。后面所有操作,都围绕这个目标展开。
2. alist不是“网盘挂载器”,而是HTTP协议层的地址路由器
很多新手把alist当成“另一个网盘客户端”,装完就急着点“添加存储”,填115账号密码,然后发现Emby里啥也没扫出来,或者扫出来全是乱码文件名。这就错了。alist的本质,不是帮你“登录网盘”,而是为你已有的存储资源,生成一套标准化、无状态、可预测的HTTP访问路径。它不关心你存的是115、阿里云OSS还是家里的Samba共享,只关心一件事:当浏览器或Emby发来一个GET请求,比如http://alist:5244/115/电影/阿凡达.mp4,alist能不能在100毫秒内,把这个请求准确转发到115的真实下载链接,并返回302重定向,让播放器直接去那个链接拿数据。
这就决定了alist的配置核心,从来不是“账号密码”,而是存储策略(Storage Policy)与路径映射(Path Mapping)的精确设计。以115为例,官方文档推荐用“115 WebDAV”方式接入,但实测下来,WebDAV在alist里稳定性极差:上传大文件易中断、目录刷新延迟高、遇到特殊字符文件名直接报错。真正稳的方案,是用115官方提供的“离线下载直链”能力——你手动在115网页端把资源离线到“alist专用目录”,然后在alist里添加“115 Cookie”存储,填入你浏览器F12抓包拿到的cookie字段(注意是完整SESSDATA=xxx; 115_lang=zh; ...字符串,不是单个值),并设置root_folder为你离线到的那个目录ID(比如123456789)。这样alist每次请求,都是带着有效会话去115后台拉取直链,成功率接近100%。
再来看路径映射的关键细节。alist默认生成的链接是http://alist:5244/d/115/电影/阿凡达.mp4,这个/d/前缀是“下载模式”,会触发alist内部下载再吐出,完全违背“直链”初衷。必须改成/p/前缀,即“预览模式”,它才会返回302跳转到115原始直链。但Emby默认不认/p/开头的路径,怎么办?两个方案:
- 方案A(推荐):在alist的
settings.json里,把site_url设为http://your-domain.com(你的域名),再把cdn_url设为https://cdn.your-domain.com(CDN加速域名),然后在Nginx反向代理里,把所有/p/开头的请求,rewrite成/d/并加X-Forwarded-For头,同时把响应头Location里的/d/替换成/p/。听起来复杂?其实就三行Nginx配置:
location /p/ { proxy_pass http://alist:5244/d/; proxy_set_header X-Forwarded-For $remote_addr; sub_filter '/d/' '/p/'; sub_filter_once off; }- 方案B(简化版):直接在Emby后台的“媒体库”设置里,把路径填成
http://alist:5244/p/115/,然后安装Emby插件“Emby AList Plugin”,它会自动把/p/路径转换为Emby可识别的媒体源。但插件更新慢,遇到alist新版本常有兼容问题,属于“能用但不推荐长期依赖”。
提示:alist的
/p/模式返回302跳转,对播放器有要求。iOS上的Infuse、nPlayer完全支持;安卓的MX Player需开启“网络流媒体”选项;TV端的Kodi需安装inputstream.adaptive插件。如果你家电视盒子是老款晶晨芯片,建议优先测试/d/模式+Emby转码,虽然牺牲画质但兼容性无敌。
3. Emby的“直链播放”不是开关一开就成,而是元数据、路径、权限的三重校准
Emby界面右上角那个“直链播放”开关,90%的人以为打开它,所有视频就自动走直链了。错。这个开关只是“允许Emby尝试直链”,真正的执行权,在三个更底层的地方:媒体库路径配置、文件系统权限、以及播放器客户端的能力协商。
先说媒体库路径。Emby扫描媒体时,必须把alist暴露的HTTP路径,当作“本地文件路径”来处理。比如alist的http://alist:5244/p/115/对应你115里的“电影”文件夹,那Emby新建媒体库时,“文件夹路径”必须填http://alist:5244/p/115/,而不是/mnt/115/(那是alist容器内部的挂载点,Emby根本访问不到)。这里有个致命陷阱:Emby的Web界面会自动把http://开头的路径识别为“远程路径”,并禁用“直链播放”选项。破解方法是——用Emby的API手动创建媒体库。用curl发个POST请求:
curl -X POST "http://emby:8096/emby/Library/MediaFolders" \ -H "X-Emby-Token: your_api_key" \ -H "Content-Type: application/json" \ -d '{ "Name": "115电影", "Path": "http://alist:5244/p/115/", "NetworkPath": "http://alist:5244/p/115/", "EnabledForVideo": true, "RefreshIntervalDays": 1 }'这个API调用会强制Emby把HTTP路径当成本地路径处理,直链开关才能生效。
再说文件系统权限。Emby容器默认以uid=998(gid=998)运行,而alist容器里文件的owner是root。如果alist没正确配置umask或gid,Emby在扫描时会因“Permission Denied”跳过大量文件。解决方案是在docker-compose.yml里,给alist服务加上:
services: alist: # ...其他配置 environment: - UID=998 - GID=998 # 或者更彻底:用user: "998:998"这样alist生成的所有HTTP响应头,都会带上正确的Content-Length和Last-Modified,Emby才能准确判断文件是否已变更、是否需要重新扫描。
最后是播放器能力协商。Emby不是把直链URL直接扔给播放器,而是先发一个HEAD请求,检查目标URL的Content-Type(必须是video/mp4、video/x-matroska等)、Accept-Ranges(必须是bytes)、Content-Length(不能为*)。如果115直链返回Content-Type: application/octet-stream,Emby就会放弃直链,切回转码。这时就得改alist的storage配置,在115存储的driver参数里,加上mime_type_mapping: {"mkv": "video/x-matroska", "mp4": "video/mp4"},强制指定MIME类型。
注意:Emby Premiere订阅解锁的“直链播放”功能,本质是解除播放器端的DRM限制。如果你用的是免费版Emby,即使后端配置全对,iOS端Infuse仍可能因Apple ATS安全策略拒绝加载HTTP直链(必须HTTPS)。此时必须用Nginx做SSL终止,把
https://emby.your-domain.com/videos/反向代理到http://alist:5244/p/115/,让播放器看到的永远是HTTPS链接。
4. docker-compose不是魔法咒语,是服务依赖、网络隔离与健康检查的精密编排
网上流传的docker-compose.yml模板,十有八九是直接复制粘贴的“能跑就行”版本:alist、Emby、MySQL全堆在一个default网络里,depends_on只写服务名,restart: always一加了事。结果就是——某天alist升级到v3.30,MySQL突然连不上,Emby疯狂刷database connection refused日志,全家影音瘫痪两小时。这不是运气差,是docker-compose的依赖逻辑被严重误读。
depends_on在Docker Compose v2+中,只控制容器启动顺序,不保证服务就绪。比如depends_on: [mysql],只表示alist容器会在mysql容器start之后才start,但mysql容器start完成时,MySQL服务可能还在初始化InnoDB引擎,根本没监听3306端口。正确做法是:用healthcheck定义服务就绪标准,并用condition: service_healthy替代depends_on。以MySQL为例:
services: mysql: image: mysql:8.0.33 # 注意:别写8.0.34,Hub上没有这个tag healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] timeout: 20s retries: 10 start_period: 40s然后在alist服务里:
alist: # ...其他配置 depends_on: mysql: condition: service_healthy这样alist容器启动前,会等MySQL健康检查通过10次,确保服务真正可用。
再看网络隔离。把alist、Emby、MySQL全放在default网络,看似方便,实则埋雷。比如alist的Web管理界面默认开放5244端口,如果宿主机防火墙没关严,公网IP+5244就能直接访问alist后台——等于把你的115 Cookie裸奔在互联网上。正确姿势是:划分三个独立网络——frontend(供Nginx和外部访问)、backend(alist与Emby通信)、database(仅MySQL与alist通信)。docker-compose.yml里这么写:
networks: frontend: driver: bridge backend: driver: bridge database: driver: bridge services: nginx: networks: [frontend] emby: networks: [frontend, backend] alist: networks: [backend, database] mysql: networks: [database]这样,alist的5244端口只在backend网络内可达,Emby通过http://alist:5244调用,外部完全无法触达。
最后是健康检查的实战价值。我曾经遇到alist容器内存泄漏,RSS涨到2GB后卡死,但docker ps显示状态仍是Up 2 days。加了健康检查后,docker ps里alist状态变成Unhealthy,配合restart: on-failure,Docker自动重启容器,整个过程无需人工干预。健康检查脚本就一行:
alist: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5244/ping"] interval: 30s timeout: 10s retries: 3/ping接口是alist内置的轻量健康检测,毫秒级响应,比查数据库靠谱多了。
提示:
docker-compose up -d的意思是“detached mode”,后台运行。但很多人执行后立刻去浏览器访问,发现打不开,就以为失败了。其实容器启动需要时间,特别是MySQL首次初始化要30秒以上。正确做法是:docker-compose up -d && docker-compose logs -f --tail=100,实时看日志,直到出现mysqld: ready for connections和alist: server started才算真正就绪。
5. 从“能播”到“好播”的临门一脚:Nginx反向代理的七层流量调度
当alist和Emby都跑起来了,你可能会发现:用http://alist:5244/p/115/阿凡达.mp4在浏览器里能直接下载,但Emby里点播放却黑屏;或者手机App里能播,但电视盒子上提示“无法连接服务器”。这些问题的根源,往往不在alist或Emby本身,而在HTTP请求的七层特征被中间设备篡改。比如运营商劫持、企业防火墙过滤、甚至某些智能路由器的QoS策略,会把Range: bytes=0-这种分片请求给截断,导致播放器无法seek、无法缓冲。这时候,Nginx反向代理就不是“可选项”,而是“必选项”。
Nginx在这里干三件事:
- SSL终止:把HTTPS请求解密,再以HTTP发给alist/Emby,避免证书链问题;
- Header净化:移除可能触发运营商劫持的
User-Agent、Accept-Encoding等头; - Range请求透传:确保
Range头100%原样转发,这是4K视频流畅播放的生命线。
一个生产级的Nginx配置,长这样:
upstream alist_backend { server alist:5244; } upstream emby_backend { server emby:8096; } server { listen 443 ssl http2; server_name emby.your-domain.com; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; location /videos/ { proxy_pass http://alist_backend/p/115/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 关键:透传Range请求 proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; proxy_redirect off; # 防劫持:强制清除可能被篡改的头 proxy_hide_header X-Powered-By; proxy_hide_header Server; } location / { proxy_pass http://emby_backend/; # Emby的WebSocket需要特殊处理 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }这段配置里,/videos/路径专门代理alist的直链,/路径代理Emby主服务。重点看proxy_buffering off和proxy_cache off:如果开启缓冲,Nginx会先把整个视频文件缓存到内存再吐给播放器,这完全违背“直链”初衷,且内存爆炸。proxy_http_version 1.1和Connection ''则是为了正确处理HTTP/1.1的keep-alive和Range分片。
更关键的是,这个配置让所有直链URL变成https://emby.your-domain.com/videos/阿凡达.mp4。为什么非要用域名?因为:
- iOS/iPadOS强制HTTPS,HTTP直链会被ATS拦截;
- 某些安卓盒子的WebView内核,对HTTP混合内容(HTTPS页面加载HTTP视频)有严格限制;
- 域名可绑定CDN,把115直链流量卸载到边缘节点,减轻alist服务器压力。
CDN配置极其简单:在Cloudflare或腾讯云CDN控制台,把emby.your-domain.com的/videos/*路径,源站设置为你的Nginx服务器IP,缓存规则设为“不缓存”,然后在Nginx里加一行:
location /videos/ { # ...前面的配置 # CDN回源时,用真实IP而非CDN IP set_real_ip_from 103.21.244.0/22; real_ip_header CF-Connecting-IP; }这样,alist日志里看到的就是用户真实IP,方便后续做访问统计或限速。
实操心得:第一次配Nginx反代,务必用
curl -I -H "Range: bytes=0-1023" https://emby.your-domain.com/videos/阿凡达.mp4测试。如果返回206 Partial Content和Content-Range: bytes 0-1023/1234567890,说明Range透传成功;如果返回200 OK和Content-Length: 1234567890,说明Nginx把Range头吃掉了,得检查proxy_http_version和Connection配置。
6. 真正的“逐步优化”,是监控、日志与故障自愈的闭环建设
标题里“逐步优化”四个字,最容易被忽略。很多人以为优化就是换个更快的硬盘、升级alist到最新版、或者给Emby开更多转码线程。但真正的优化,是建立一套可观测、可追溯、可自愈的运维闭环。没有这个闭环,今天调通的配置,明天一次alist升级就全崩;没有这个闭环,你永远在“救火”,而不是“防火”。
第一步,日志集中化。docker-compose logs只能看实时输出,历史日志全丢在/var/lib/docker/containers/里,找起来像考古。必须把所有服务日志,统一推送到Elasticsearch+Kibana。最轻量的方案,是用fluentd作为日志收集器:在docker-compose.yml里加一个fluentd服务,所有其他服务的logging配置指向它:
services: fluentd: image: fluent/fluentd:v1.16-1 volumes: - ./fluentd/conf:/fluentd/etc ports: - "24224:24224" alist: logging: driver: "fluentd" options: fluentd-address: "localhost:24224" tag: "alist"然后在fluentd/conf/fluent.conf里,定义解析规则:
<source> @type forward port 24224 </source> <filter alist> @type parser key_name log <parse> @type json </parse> </filter> <match **> @type elasticsearch host elasticsearch port 9200 logstash_format true </match>这样,alist的每条日志,都会带上tag: alist、container_id、timestamp,在Kibana里搜alist AND "error",5秒定位问题。
第二步,关键指标监控。光看日志不够,得量化。用Prometheus+Grafana监控三类指标:
- alist:
alist_storage_request_total{status="200"}(成功请求数)、alist_storage_request_duration_seconds_bucket(响应延迟分布); - Emby:
emby_session_active(活跃会话数)、emby_transcode_queue_length(转码队列长度); - Nginx:
nginx_http_request_duration_seconds_bucket{le="1"}(1秒内响应占比)、nginx_http_requests_total{code=~"5.."}(5xx错误率)。
这些指标,靠prometheus-node-exporter是拿不到的,得用各服务的exporter:alist自带/metrics端点(v3.28+),Emby需装emby-prometheus-exporter插件,Nginx用nginx-vts-exporter。
第三步,故障自愈。当监控发现alist_storage_request_duration_seconds_bucket{le="5"} < 0.95(95%请求超5秒),自动触发告警并执行修复脚本:
#!/bin/bash # check_alist_health.sh if ! curl -sf http://alist:5244/ping | grep -q "pong"; then echo "$(date): alist unresponsive, restarting..." >> /var/log/alist-autoheal.log docker-compose restart alist sleep 30 # 重启后验证 if ! curl -sf http://alist:5244/ping | grep -q "pong"; then echo "$(date): alist restart failed, alert admin" >> /var/log/alist-autoheal.log # 发送微信/邮件告警 fi fi这个脚本用cron每5分钟跑一次,比等用户投诉再处理,效率高10倍。
最后分享一个血泪教训:某次alist升级后,
/p/路径返回的302跳转,Location头里带了?sign=xxx参数,但Emby解析时把?后面全截掉了,导致直链失效。查了3小时日志,才发现是alist的sign_query配置项默认开启。关掉它:sign_query: false,问题立解。所以“逐步优化”的终点,不是功能多强大,而是每一次变更,都有对应的监控、日志、回滚预案。这才是家庭影音系统真正“稳如磐石”的根基。
