本篇记录了如何优化海外服务器搭建Emby的观看体验,文章内的方法包括:使用亚洲线路好的服务器反向代理 、国内NAS等设备中转 和 Nginx劫持返回云盘直链。

方案设想

目前使用的是Racknerd洛杉矶VPS作为Emby服务器,除了晚高峰,其余时间播放速度体验尚好,但就在晚高峰,观看体验骤降,速度一度跑不上100kb/s,使用体验堪称国内运营商超出限制的无限流量套餐。

中转

脑中蹦出的第一个方案则是全程挂梯子 脑中蹦出的第一个方案当然是中转,使用国内大带宽Nat或香港日本韩国的vps反代emby服务器。

中转服务器的选择

国内Nat先不说价格,除了广东上海,很难说其他地理位置的机子中转访问emby的体验和直连观看有什么区别。其次是域名解析到国内ipv4需要备案,端口也不是常规的80、443、8096。线路优秀的香港日本VPS价格昂贵,但我的标题中打上了“低价”字样,所以祭出第一个方案:Azure100订阅

Azure100 香港

之前的微软香港云还是可以直连的,但是经历了拔线风波之后统一绕路再入境。不过Emby 嘛又不是什么奇怪的东西,延迟什么50,100体验没什么区别,不过azure100的服务器月流量到底是15G还是115G就不好说了,网上吵了一年还是没有结果,不过网上冲浪的时候发现一个网友没注意流量跑了2000+rmb… 微软的东西还是小心点好,so 15G月流量,想跑emby,全剧终

CDN

TL & DR :Cloudflare CDN不如直连美西,微软CDN体验极佳但是流量费近1元1G,用国内CDN没有备案,尝试阿里云国际CDN体验如同Cloudflare(甚至还不如),全剧终…

其实在这方面还是有做过尝试的,比如cloudflare优选IP。在当时那个时间段短暂体验过一会儿Cloudflare台湾高雄机房,体验非常好,三网延迟低至50ms(比az香港都好)。不过Cloudflare嘛,线路好的机房注定会被淘汰的。不出一个月,台湾高雄机房的IP段全面被墙。

同时由于Cloudflare使用的知名度之高,国内外同样有一些线路好的VPS反向代理Cloudflare当地节点实现加速的行为。但由于常规的反向代理不会验证使用者,导致了有一些IP可以被随意使用,具体可以查看遨游者的博文:反代/中转cloudflare的安全隐患与隐患利用。网上有一些专门扫面这些IP的项目,但是白嫖使用别人的机器优化自己的使用体验总是违背道德的,同时使用的人数一但躲起来免不了落得和cloudflare一样的下场。

树莓派

这时目光来到了手上的树莓派上,其实用家宽公网ipv6+DDNS,再跑上一个clash用于加速到emby的连接应该体验也不错,30M的上行挤挤也够用,只不过纯ipv6的访问体验在某些地方可能不佳。

Emby播放直链

这是本篇的重头戏,利用nginx反代搭配alist,可以把emby的播放直链劫持导向网盘直链,视频流量完全不经过服务器,这时的播放体验和使用的网盘的速度挂钩。

当然缺点就是无法转码播放,因为视频资源完全不通过服务器。不过就服务器那点配置估计也跑不动转码吧(

前提条件就是一个国内体验不错的大容量网盘,谷歌云盘直接淘汰 除此之外就是阿里云和onedrive的选择。不过阿里云的容量只有3t,并且有和谐资源的嫌疑,相比之下微软E5订阅的5T容量看起来就非常不错,唯二的缺点可能就是微软api申请的风控策略,并且也不知道什么时候会凉。(不过播放也是用alist调用微软的api,间接给E5续命)

具体实施

香港中转

使用nginx反代cf节点非常简单,这里推荐BT面板的仿制品**mdserver-web** 不用担心BT面板的后门,也可以获得相似的体验。

具体做起来只有两个坑:

  1. 申请ssl证书疯狂报请关闭301重定向,即是从来没开启过
  2. 反代cf站点报错403

第一点可能是因为面板的逻辑问题,首先配置了反向代理就会出现这种情况,这时再关闭反向代理也会一直提示 请关闭301重定向,具体解决方法就是删了站点重建,先申请ssl证书再配置反向代理。

第二点需在反代配置中加上 proxy_ssl_server_name on;

CDN

这里主要是用了ip-scanner /cloudflare 项目,此项目收集了全网用于反代cf的机子,只需要稍加配置即可食用。

这里我让ChatGPT写了一个python脚本用于扫描此项目里有哪些ip可用,ChatGPT使用request库挨个请求每个ip的80,443端口,返回403就请求/cdn-cgi/trace确定是用于中转的ip,可用就填进字典里;不过感觉这样效率很低,如果用上述博文《反代/中转cloudflare的安全隐患与隐患利用》中的方法应该会快一些

对于地区的选择,如果选择国内机子搭配的域名仍然需要备案。。并且只有广东的服务器体验会好一些,相比之下除了使用香港台湾新加坡的机子体验会很不错。

项目中的大部分ip会每日更新,不过我还是会喜欢几个月前的老ip,总给人一种“已经活了这么久的ip应该会活的更长一点”的错觉。

树莓派中转

同样是使用mdserver-web搭建中转,同样需要添加proxy_ssl_server_name on;避免访问403,这里卡住的点是如何用将nginx反代的网站用socks再代理一下,具体文章参考Nginx 如何与 Socat 配合使用

1. 安装支持socks5的socat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sudo apt install -y autoconf git yodl curl make 
# 拉取源码
git clone https://github.com/runsisi/socat.git
# 进入目录
cd socat
# 处理配置
autoconf
./configure --prefix=/usr
# 编译
make
# 安装
sudo make install
# 检查是否成安装
socat -h

2.用screen做守护进程

原文用的systemd,我感觉screen更加方便一些。假设本地socks5代理端口是1080,将代理完的服务暴露在1081端口:

1
2
3
4
5
6
7
8
9
10
screen -R socat
... #测试
socat TCP4-LISTEN:1081,reuseaddr,fork SOCKS5:127.0.0.1:cip.cc:80,socks5port=1080
# CTRL+A+D挂起
curl -v -H "Host: cip.cc" 127.0.0.1:1081 #测试是否代理成功
...

screen -r socat
socat TCP4-LISTEN:1081,reuseaddr,fork SOCKS5:127.0.0.1:Emby服务器IP:8096,socks5port=1080
# CTRL+A+D挂起

之后在nginx中直接反代127.0.0.1:1081即可

实际测试起来只要树莓派访问代理的速度不错,体验就很好,我的媒体库里的视频都不大,也没有上行触及30Mbps的上限

Emby直链

GitHub仓库: **embyExternalUrl**,本机已经装好alist,配置好网盘,部署好emby。按理说alist不部署在本机也是可以的,但是实际测试后请求alist总是超时

克隆仓库到本地后修改docker-compose文件(因为已经部署好了alist):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

version: '3.5'

services:

service.nginx:
image: nginx:alpine
container_name: emby-nginx
ports:
- 8095:80
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/embyCache:/var/cache/nginx/emby
restart: always

接着修改emby.js文件:vim nginx/conf.d/emby.js

关于rclone的挂载目录指的是rclone挂载emby媒体库的路径,如我将网盘/movie挂载在本地/mnt目录下,则填/mnt,根目录下就填/即可。Emby的视频文件路径在/mnt/movie/xxx,但是alist中我的网盘路径是/movie/xxx,所以需要脚本把挂载目录去除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
//author: @bpking  https://github.com/bpking1/embyExternalUrl
//查看日志: "docker logs -f -n 10 emby-nginx 2>&1 | grep js:"
async function redirect2Pan(r) {
//根据实际情况修改下面的设置
const embyHost = 'http://172.17.0.1:8096'; //这里默认emby/jellyfin的地址是宿主机,要注意iptables给容器放行端口
const embyMountPath = '/mnt'; // rclone 的挂载目录, 例如将od, gd挂载到/mnt目录下: /mnt/onedrive /mnt/gd ,那么这里 就填写 /mnt
const alistToken = 'alsit-123456'; //alist token, 在alist后台查看
const alistAddr= 'http://172.17.0.1:5244'; //访问宿主机上5244端口的alist地址, 要注意iptables给容器放行端口
const embyApiKey = 'f839390f50a648fd92108bc11ca6730a'; //emby/jellyfin api key, 在emby/jellyfin后台设置
const alistPublicAddr = 'http://youralist.com:5244'; // alist公网地址, 用于需要alist server代理流量的情况, 按需填写

//fetch mount emby/jellyfin file path
const regex = /[A-Za-z0-9]+/g;
const itemId = r.uri.replace('emby', '').replace(/-/g, '').match(regex)[1];
const mediaSourceId = r.args.MediaSourceId ? r.args.MediaSourceId : r.args.mediaSourceId;
const Etag = r.args.Tag
let api_key = r.args['X-Emby-Token'] ? r.args['X-Emby-Token'] : r.args.api_key;
api_key = api_key ? api_key : embyApiKey;

const itemInfoUri = `${embyHost}/Items/${itemId}/PlaybackInfo?MediaSourceId=${mediaSourceId}&api_key=${api_key}`;
r.warn(`itemInfoUri: ${itemInfoUri}`);
const embyRes = await fetchEmbyFilePath(itemInfoUri, Etag);
if (embyRes.startsWith('error')) {
r.error(embyRes);
r.return(500, embyRes);
return;
}
r.warn(`mount emby file path: ${embyRes}`);

//fetch alist direct link
const alistFilePath = embyRes.replace(embyMountPath, '');
const alistFsGetApiPath = `${alistAddr}/api/fs/get`;
let alistRes = await fetchAlistPathApi(alistFsGetApiPath, alistFilePath, alistToken);
if (!alistRes.startsWith('error')) {
alistRes = alistRes.includes('http://172.17.0.1') ? alistRes.replace('http://172.17.0.1',alistPublicAddr) : alistRes;
r.warn(`redirect to: ${alistRes}`);
r.return(302, alistRes);
return;
}
if (alistRes.startsWith('error403')) {
r.error(alistRes);
r.return(403, alistRes);
return;
}
if (alistRes.startsWith('error500')) {
const filePath = alistFilePath.substring(alistFilePath.indexOf('/', 1));
const alistFsListApiPath = `${alistAddr}/api/fs/list`;
const foldersRes = await fetchAlistPathApi(alistFsListApiPath, '/', alistToken);
if (foldersRes.startsWith('error')) {
r.error(foldersRes);
r.return(500, foldersRes);
return;
}
const folders = foldersRes.split(',').sort();
for (let i = 0; i < folders.length; i++) {
r.warn(`try to fetch alist path from /${folders[i]}${filePath}`);
let driverRes = await fetchAlistPathApi(alistFsGetApiPath, `/${folders[i]}${filePath}`, alistToken);
if (!driverRes.startsWith('error')) {
driverRes = driverRes.includes('http://172.17.0.1') ? driverRes.replace('http://172.17.0.1',alistPublicAddr) : driverRes;
r.warn(`redirect to: ${driverRes}`);
r.return(302, driverRes);
return;
}
}
r.error(alistRes);
r.return(404, alistRes);
return;
}
r.error(alistRes);
r.return(500, alistRes);
return;
}

async function fetchAlistPathApi(alistApiPath, alistFilePath, alistToken) {
const alistRequestBody = {
"path": alistFilePath,
"password": ''
}
try {
const response = await ngx.fetch(alistApiPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8',
'Authorization': alistToken
},
max_response_body_size: 65535,
body: JSON.stringify(alistRequestBody)
})
if (response.ok) {
const result = await response.json();
if (result === null || result === undefined) {
return `error: alist_path_api response is null`;
}
if (result.message == 'success') {
if (result.data.raw_url) {
return result.data.raw_url;
}
return result.data.content.map(item => item.name).join(',');
}
if (result.code == 403) {
return `error403: alist_path_api ${result.message}`;
}
return `error500: alist_path_api ${result.code} ${result.message}`;
}
else {
return `error: alist_path_api ${response.status} ${response.statusText}`;
}
} catch (error) {
return (`error: alist_path_api fetchAlistFiled ${error}`);
}
}

async function fetchEmbyFilePath(itemInfoUri, Etag) {
try {
const res = await ngx.fetch(itemInfoUri, {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8',
'Content-Length': 0,
},
max_response_body_size: 65535,
});
if (res.ok) {
const result = await res.json();
if (result === null || result === undefined) {
return `error: emby_api itemInfoUri response is null`;
}
if (Etag) {
const mediaSource = result.MediaSources.find(m => m.ETag == Etag);
if (mediaSource && mediaSource.Path) {
return mediaSource.Path;
}
}
return result.MediaSources[0].Path;
}
else {
return (`error: emby_api ${res.status} ${res.statusText}`);
}
}
catch (error) {
return (`error: emby_api fetch mediaItemInfo failed, ${error}`);
}
}

export default { redirect2Pan };

docker-compose up -d 启动即可,默认暴露端口是8095,如果无法正常访问docker-compose logs -f查看日志