├── FAQ.md
├── LICENSE
├── README.md
├── _headers
├── alistWebAddExternalUrl
├── README.md
├── alistWebLaunchExternalPlayer.js
└── preview
│ ├── preview01.png
│ └── preview02.png
├── donate
├── Alipay.jpg
├── BTC(SegWit).jpg
├── USDT-Tron (TRC20).jpg
└── Wechat.jpg
├── emby2Alist
├── CHANGELOG.md
├── README.md
├── docker
│ ├── docker-compose.yml
│ └── nginx-emby.syno.json
└── nginx
│ ├── conf.d
│ ├── api
│ │ ├── alist-api.js
│ │ └── emby-api.js
│ ├── common
│ │ ├── events.js
│ │ ├── live-util.js
│ │ ├── periodics.js
│ │ ├── url-util.js
│ │ └── util.js
│ ├── config
│ │ ├── constant-common.js
│ │ ├── constant-ext.js
│ │ ├── constant-mount.js
│ │ ├── constant-nginx.js
│ │ ├── constant-pro.js
│ │ ├── constant-strm.js
│ │ ├── constant-symlink.js
│ │ └── constant-transcode.js
│ ├── constant.js
│ ├── custome
│ │ └── location
│ │ │ └── default.conf
│ ├── docs
│ │ ├── UA.txt
│ │ └── test-data.md
│ ├── emby.conf
│ ├── emby.js
│ ├── exampleConfig
│ │ ├── constant-all.js
│ │ └── constant-main.js
│ ├── includes
│ │ ├── http.conf
│ │ ├── https.conf
│ │ ├── proxy-header.conf
│ │ └── server-group.conf
│ └── modules
│ │ ├── emby-example.js
│ │ ├── emby-items.js
│ │ ├── emby-live.js
│ │ ├── emby-playback-info.js
│ │ ├── emby-search.js
│ │ ├── emby-system.js
│ │ ├── emby-transcode.js
│ │ ├── emby-v-media.js
│ │ └── ngx-ext.js
│ └── nginx.conf
├── embyAddExternalUrl
├── docker-compose.yml
└── nginx
│ ├── conf.d
│ ├── emby.conf
│ └── externalUrl.js
│ └── nginx.conf
├── embyWebAddExternalUrl
├── README.md
├── embyLaunchPotplayer.js
├── icons
│ ├── ExternalPlayer.css
│ ├── icon-Copy.webp
│ ├── icon-DDPlay.webp
│ ├── icon-FigPlayer.webp
│ ├── icon-Fileball.webp
│ ├── icon-IINA.webp
│ ├── icon-MPV.webp
│ ├── icon-MXPlayer.webp
│ ├── icon-MXPlayerPro.webp
│ ├── icon-NPlayer.webp
│ ├── icon-OmniPlayer.webp
│ ├── icon-PotPlayer.webp
│ ├── icon-SenPlayer.webp
│ ├── icon-StellarPlayer.webp
│ ├── icon-VLC.webp
│ ├── icon-infuse.webp
│ └── min
│ │ ├── icon-Copy.webp
│ │ ├── icon-DDPlay.webp
│ │ ├── icon-FigPlayer.webp
│ │ ├── icon-Fileball.webp
│ │ ├── icon-IINA.webp
│ │ ├── icon-MPV.webp
│ │ ├── icon-MXPlayer.webp
│ │ ├── icon-MXPlayerPro.webp
│ │ ├── icon-NPlayer.webp
│ │ ├── icon-OmniPlayer.webp
│ │ ├── icon-PotPlayer.webp
│ │ ├── icon-SenPlayer.webp
│ │ ├── icon-StellarPlayer.webp
│ │ ├── icon-VLC.webp
│ │ └── icon-infuse.webp
├── iconsExt.js
└── preview
│ ├── preview01.png
│ └── preview02.png
└── plex2Alist
├── CHANGELOG.md
├── README.md
├── docker
├── docker-compose.yml
└── nginx-plex.syno.json
└── nginx
├── conf.d
├── api
│ └── alist-api.js
├── common
│ ├── events.js
│ ├── periodics.js
│ ├── url-util.js
│ └── util.js
├── config
│ ├── constant-common.js
│ ├── constant-ext.js
│ ├── constant-mount.js
│ ├── constant-pro.js
│ ├── constant-strm.js
│ ├── constant-symlink.js
│ └── constant-transcode.js
├── constant.js
├── custome
│ └── location
│ │ └── default.conf
├── exampleConfig
│ ├── constant-all.js
│ └── constant-main.js
├── includes
│ ├── http.conf
│ ├── https.conf
│ └── proxy-header.conf
├── modules
│ ├── ngx-ext.js
│ └── plex-example.js
├── plex.conf
└── plex.js
└── nginx.conf
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 jerry1119
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### 主要功能
2 | | 名称 | 功能 |
3 | | - | :- |
4 | | [emby2Alist](./emby2Alist/README.md) | emby/jellyfin 重定向到 alist 直链 |
5 | | [embyAddExternalUrl](./alistWebAddExternalUrl/README.md) | emby/jellyfin 全客户端(除老TV端)添加调用外部播放器按钮 |
6 | | [embyWebAddExternalUrl](./embyWebAddExternalUrl/README.md) | emby/jellyfin/alistWeb 调用外部播放器用户脚本,只支持网页 |
7 | | [plex2Alist](./plex2Alist/README.md) | plex 重定向到 alist 直链 |
8 |
9 | ### 常见问题
10 | [FAQ](./FAQ.md)
11 |
12 | # embyExternalUrl
13 |
14 | ### emby调用外部播放器服务端脚本
15 |
16 | 通过nginx的njs模块运行js脚本,在emby视频的外部链接处添加调用外部播放器链接,所有emby官方客户端可用,
17 | 不支持老 TV 客户端等没有外部媒体数据库链接处的情况,另外需要注意电视端内置的 web view 实现方式的兼容性
18 |
19 | 
20 |
21 |
22 | ### 部署方式,任选一种
23 |
24 | ## 一.单独使用方式
25 |
26 | 这里采用的是docker安装,也可以不使用docker,自己安装njs模块
27 |
28 | 先下载脚本:
29 | ```bash
30 | wget https://github.com/bpking1/embyExternalUrl/releases/download/v0.0.1/addExternalUrl.tar.gz && mkdir -p ~/embyExternalUrl && tar -xzvf ./addExternalUrl.tar.gz -C ~/embyExternalUrl && cd ~/embyExternalUrl
31 | ```
32 |
33 | 然后看情况修改externalUrl.js文件里面的serverAddr
34 |
35 | tags 和 groups是从视频版本中提取的关键字作为外链的名字,不需要就不用改
36 |
37 | emby.conf默认反代emby-server是本机的8096端口,按需修改
38 |
39 | docker-compose.yml默认映射8097端口,按需修改
40 |
41 | 然后启动docker
42 | ```
43 | docker-compose up -d
44 | ```
45 | 访问8097端口,在视频信息页面的底部就添加了外部播放器链接
46 |
47 | 日志查看:
48 | ```
49 | docker logs -f nginx-embyUrl 2>&1 | grep error
50 | ```
51 |
52 | ## 二.与 emby2Alist 整合并共存
53 |
54 | 1. 将 externalUrl.js 放到 emby2Alist 的 conf.d 下与 emby.js 处于同一级
55 |
56 | 2. 将 emby.conf 中的 ## addExternalUrl SETTINGS ## 之间的内容复制到 emby2Alist 的 emby.conf 中 location / 块的上面
57 |
58 | 3. 将 emby.conf 最上面的 js_import 复制到 emby2Alist 的 emby.conf 相同位置
59 |
60 | 4. 重启 ngixn 或者输入命令 nginx -s reload 重载配置文件,注意此时使用 emby2Alist 的 nginx 对应端口访问
61 |
62 | ### emby调用外部播放器用户脚本,只支持网页:
63 |
64 | [篡改猴地址](https://greasyfork.org/zh-CN/scripts/514529)
65 |
66 | ## 捐赠
67 |
68 | 如果这个项目对你有帮助,欢迎点亮一颗⭐️,假如条件允许,可以请我喝杯咖啡,感谢你对开源精神的认可与支持!
69 |
70 | ### Payments
71 | Alipay:
72 | Wechat:
73 |
74 | BTC(SegWit):
75 | USDT-Tron (TRC20):
76 |
77 | Binance ID/币安 ID: 1041685683
78 |
79 | BTC
80 | 1. Network: BTC(SegWit)
81 | 2. Deposit Address: bc1qvr80l9juwkg94mpe55wafpwwnqtzjfs9tje8zn
82 |
83 | USDT
84 | 1. Network: Tron (TRC20)
85 | 2. Deposit Address: TSsmBGRhtN2AZHSG7WtvYN2UZ3dLdtMpUN
86 |
--------------------------------------------------------------------------------
/_headers:
--------------------------------------------------------------------------------
1 | # 为全部 js 文件设置
2 | /*.js
3 | Content-Type: text/javascript; charset=utf-8
--------------------------------------------------------------------------------
/alistWebAddExternalUrl/README.md:
--------------------------------------------------------------------------------
1 |
2 | ### alist 调用外部播放器用户脚本,支持网页和服务端:
3 |
4 | greasyfork 地址: https://greasyfork.org/zh-CN/scripts/494829
5 |
6 | 按需更改的地方:
7 |
8 | 1.代码内部变量
9 |
10 | ```js
11 | // 是否替换原始外部播放器
12 | const replaceOriginLinks = true;
13 | // 是否使用内置的 Base64 图标
14 | const useInnerIcons = true;
15 | // 移除最后几个冗余的自定义开关
16 | const removeCustomBtns = false;
17 | ```
18 |
19 | 效果:
20 |
21 | AList V3
22 | 
23 |
24 | AList V2
25 | 
26 |
27 | 一. 浏览器单独使用方法
28 |
29 | 1. 安装 [Tampermonkey](https://www.tampermonkey.net) 拓展插件,
30 | 2. 进入脚本详情页点击安装
31 | 3. 打开已安装的脚本列表,点击启用按钮,再点击最后边的编辑按钮,选择设置选项卡,
32 | 编辑 包括/排除,去掉 原始匹配 勾选的泛化全域名,在 用户匹配 中添加响应的 alist 域名,不能包含端口号,会被忽略
33 |
34 | 二. 添加到服务端 alist 网站上
35 |
36 | 1. 登录 alist 管理后台 -> 设置 -> 全局 -> 自定义头部,填入脚本地址即可
37 |
38 | ```js
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | ```
47 |
48 | #### 其余注意事项请参照
49 | greasyfork 地址: https://greasyfork.org/en/scripts/459297-embylaunchpotplayer
50 |
51 | ### CHANGELOG
52 |
53 | #### 1.1.3
54 | 1. fix(alistWebLaunchExternalPlayer): 提供内部变量移除最后几个冗余的自定义开关
55 |
56 | #### 1.1.2
57 | 1. feat(alistWebLaunchExternalPlayer): 隐藏其他平台播放器开关数据隔离,添加多开Potplayer开关
58 |
59 | #### 1.1.1
60 | 1. 添加几个播放器支持
61 | 2. 默认开启隐藏其他平台播放器图标
62 |
63 | #### 1.1.0
64 | 1. 修复剪切板 API 兼容性
65 |
66 | #### 1.0.9
67 | 1. 修复 Google Chrome Version >= 130 导致的 PotPlayer 拉起播放错误,但注意不要禁用剪切板权限
68 | 2. 意外修复了 PotPlayer 串流的中文标题支持问题
69 |
70 | #### 1.0.8
71 | 1. 修复 mpv-handler 编码错误
72 | 2. 更换 @match 为严格匹配以兼容暴力猴
73 |
74 | #### 1.0.7
75 | 1. 再次修复 URL 编码错误
76 |
77 | #### 1.0.6
78 | 1. 优先使用本地 base64 图标提升加载速度
79 |
80 | #### 1.0.5
81 | 1. 修复 MX 错误的注释内容
82 |
83 | #### 1.0.4
84 | 1. 延迟加载点以适配服务端自定义头部
85 |
86 | #### 1.0.3
87 | 1. 兼容 AList V2
88 |
89 | #### 1.0.2
90 | 1. 降低 token 依赖适配第三方网站
91 |
92 | #### 1.0.1
93 | 1. 修复错误的 URL 双重编码
94 |
--------------------------------------------------------------------------------
/alistWebAddExternalUrl/preview/preview01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chen3861229/embyExternalUrl/4ad0c67b65267269e8601dbb4ad8803ca068aa0d/alistWebAddExternalUrl/preview/preview01.png
--------------------------------------------------------------------------------
/alistWebAddExternalUrl/preview/preview02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chen3861229/embyExternalUrl/4ad0c67b65267269e8601dbb4ad8803ca068aa0d/alistWebAddExternalUrl/preview/preview02.png
--------------------------------------------------------------------------------
/donate/Alipay.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chen3861229/embyExternalUrl/4ad0c67b65267269e8601dbb4ad8803ca068aa0d/donate/Alipay.jpg
--------------------------------------------------------------------------------
/donate/BTC(SegWit).jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chen3861229/embyExternalUrl/4ad0c67b65267269e8601dbb4ad8803ca068aa0d/donate/BTC(SegWit).jpg
--------------------------------------------------------------------------------
/donate/USDT-Tron (TRC20).jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chen3861229/embyExternalUrl/4ad0c67b65267269e8601dbb4ad8803ca068aa0d/donate/USDT-Tron (TRC20).jpg
--------------------------------------------------------------------------------
/donate/Wechat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chen3861229/embyExternalUrl/4ad0c67b65267269e8601dbb4ad8803ca068aa0d/donate/Wechat.jpg
--------------------------------------------------------------------------------
/emby2Alist/README.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: emby/jellyfin 重定向到 alist 直链
3 | date: 2021/09/06 22:00:00
4 | ---
5 |
6 | ## 这篇文章的受众:
7 | 写这篇文章默认读者是 emby 用户,使用 rclone 挂载网盘,会使用 docker,因篇幅问题以上软件的使用方法不在文章范围之中,此项目不会对原有的 emby 和rclone 配置造成影响或修改
8 |
9 | ## 原理:
10 | ~~使用[aliyundrive-webdav](https://github.com/messense/aliyundrive-webdav) 项目将阿里盘转为webdav, 再~~
11 | 使用 rclone 挂载以供 emby 读取
12 | 使用[alist项目](https://github.com/Xhofe/alist) 将阿里盘及别的网盘的文件转为直链,使用 nginx 及其 njs 模块将 emby 视频播放地址劫持到 alist直链
13 | (~~暂时只测试了od,gd和阿里云盘可用,~~
14 | alist 目前支持好几种网盘,感兴趣的可以测试一下)
15 |
16 | ## 部署方式,任选一种
17 |
18 | ### 一.集成版 docker 一键部署
19 |
20 | 1. 简化配置,拉取镜像映射配置文件即可一键启动。
21 |
22 | 2. 支持 SSL,内置 acme 自动申请证书、定时更新证书。
23 |
24 | 3. 支持重启自动更新,简化更新流程。
25 |
26 | [项目地址](https://github.com/thsrite/MediaLinker?tab=readme-ov-file)
27 |
28 | ### 二.手动在已有的 nginx 环境下部署,步骤基本类似
29 |
30 | #### 1.1 nginx proxy manager(WebUI)
31 |
32 | https://github.com/chen3861229/embyExternalUrl/issues/73#issuecomment-2452921067
33 |
34 |
35 | ### 三.手动 docker 部署
36 |
37 | #### 1.先将配置文件下载到本地
38 |
39 | 注意版本号和文件名
40 | ```bash
41 | wget https://github.com/bpking1/embyExternalUrl/releases/download/v0.0.1/emby2Alist.tar.gz && mkdir -p ~/emby2Alist && tar -xzvf ./emby2Alist.tar.gz -C ~/emby2Alist && cd ~/emby2Alist
42 | ```
43 |
44 | 此时大致文件结构如下:
45 | ```javascript
46 | ~/emby2Alist
47 | ├── docker // 创建容器脚本文件夹
48 | | ├── docker-compose.yml // docker-compose 脚本,根据自身情况修改
49 | | ├── nginx-emby.syno.json // 群晖 docker 脚本,根据自身情况修改
50 | | └── nginx-jellyfin.syno.json // 群晖 docker 脚本,根据自身情况修改
51 | └── nginx // nginx 配置文件夹
52 | ├── conf.d // nginx 配置文件夹
53 | | ├── api // JS 脚本文件夹,完全不用改
54 | | ├── cert // SSL 证书文件夹,根据自身情况修改
55 | | ├── common // 通用工具类文件夹,完全不用改
56 | | ├── config // 常量拆分后配置文件,若为 constant-all.js 完全不用改,若为 constant-main.js 则需要更改对应拆分文件
57 | | ├── exampleConfig // 示例 constant 配置文件夹
58 | | ├── includes // 拆分的 conf 文件夹,http 和 https 端口在这改
59 | | ├── constant.js // 常量主配置文件,根据自身情况修改
60 | │ ├── emby-live.js // 直播相关脚本,完全不用改
61 | │ ├── emby-transcode.js // 转码相关脚本,完全不用改
62 | │ ├── emby.conf // emby 配置文件,根据自身情况修改,注意 https 默认被注释掉了
63 | │ └── emby.js // 主脚本,完全不用改
64 | └── nginx.conf // nginx 配置文件,一般不用改
65 | ```
66 |
67 | #### 2.修改示例配置
68 | 看情况修改 constant.js 中的设置项目,通常来说只需要改 alist 密码
69 | 这里默认 emby 在同一台机器并且使用 8096 端口,~~否则要修改 emby.js和emby.conf中emby的地址~~
70 |
71 | #### 3.如果不挂载阿里云盘 可以跳过这一步
72 | 修改 docker-compose.yml 中 service.ali-webdav 的 REFRESH_TOKEN
73 | 获取方法参考原项目地址: https://github.com/messense/aliyundrive-webdav
74 |
75 | #### 4.docker 部署的任选以下一种
76 | xxx 为示例目录名,请根据自身情况修改
77 |
78 | ~~前置条件1: 需要手动创建目录~~
79 | ```
80 | /xxx/nginx-emby/log
81 | /xxx/nginx-emby/embyCache
82 | ```
83 | ~~前置条件2: 需要手动移动项目配置文件~~
84 | ~~将本项目xxx2Alist/nginx/下所有文件移动到/xxx/nginx-emby/config/下面~~
85 |
86 | #### 4.1 docker-compose
87 | 启动服务: 在 ~/emby2Alist/docker 目录下执行
88 | ```bash
89 | docker-compose up -d
90 | ```
91 | 查看启动log:
92 | ```bash
93 | docker-compose logs -f
94 | ```
95 | 如果log有报错,请按照提示信息修改,常见错误可能为
96 | 1. docker端口占用冲突: 修改 docker-comopse 映射端口
97 | 2. webdav 的 refresh token 填写错误 (**如果不挂载阿里云盘则忽略**)
98 |
99 | #### 4.2 群晖 docker
100 | 容器 => 设置 => 导入 => 选择 json 配置文件 => 确认
101 |
102 | #### 5.防火墙配置
103 | 防火墙放行 5244, 8091 ~~和 8080端口~~
104 | 8080 端口为阿里盘 webdav地址,8091 端口为 emby 转直链端口与默认的 8096 互不影响
105 | 访问 5244 端口,初始密码查看 docker log 能看到 ,根据项目文档 https://github.com/Xhofe/alist 在 Alist 项目后台添加网盘
106 | 注意:
107 |
108 | 1. 添加 od,gd 盘可以直接复制 rclone 配置里面的 clientid , secret , refreshToken,不用再麻烦去重新搞一次了
109 | 2. **不使用阿里云盘可以跳过这步**
110 | alist阿里盘的refreshToken与webdav那个token是不一样的,这里需要的是要不需要referrer请求头的token,详情请参考这个[issue](https://github.com/Xhofe/alist/issues/88) , 可以用这个网页来获取 [阿里云盘 - RefreshToken (cooluc.com)](https://media.cooluc.com/decode_token/)
111 | 3. 盘名建议一致,这样获取直链更快,不一致也可以
112 |
113 | ~~添加的网盘在alist里面的名称需要与 rclone挂载的文件夹名称一样 比如挂载路径为 /mnt/ali 那么盘的名称也要叫 ali~~
114 |
115 | #### 6.如果不挂载阿里云盘 可以跳过这一步
116 | 配置 rclone,挂载网盘,这里以阿里盘 webdav 为例
117 |
118 | 使用 rclone 挂载 阿里盘 webdav
119 | 第一步name 我这里为 ali
120 | rclone config 选 webdav , 地址为http://localhost:8080 默认用户和密码都为admin
121 | rclone lsf ali: 看一下能否获取到列表
122 | 创建文件夹:
123 | mkdir -p /mnt/ali 注:此挂载文件夹的名字需要与 Alist 中的盘名相同
124 | 挂载:
125 |
126 | ```bash
127 | nohup rclone mount ali: /mnt/ali --umask 0000 --default-permissions --allow-non-empty --allow-other --buffer-size 32M --vfs-read-chunk-size 64M --vfs-read-chunk-size-limit 1G &
128 | ```
129 | 也可以写成 service
130 |
131 |
132 | #### 7.测试是否成功
133 | 访问 8091 端口打开 emby 测试直链是否生效,查看执行 log
134 | ```bash
135 | docker logs -f -n 10 nginx-emby 2>&1 | grep js:
136 | ```
137 | 或者直接查看 ../nginx/log 容器映射出来的原始 nginx error.log 业务日志
138 | 8091 端口为走直链端口,原本的 8096 端口 走 emby server 不变
139 | ~~直链播放不支持转码,转码的话只能走emby server~~
140 | 所以最好 在 emby 设置中将 播放 --> 视频 --> 互联网质量 设置为最高,
141 | ~~并且将用户的转码权限关掉,确保走直链,~~
142 | web 端各大浏览器对音频和视频编码支持情况不一,碰到不支持的情况 emby 会强制走转码而不会走直链
143 |
144 | ## 已知问题:
145 | 1. emby web 播放时如果需要使用内封的字幕,实际上是需要 embyServer 在后台用 ffmpeg 去提取的,~~ffmpeg要读取整个视频文件才能获取所有的字幕流,相当于几乎整个视频文件都要通过rclone下载,并且消耗cpu资源,对于比较大的视频文件是不现实的,所以web端建议使用外挂字幕~~,从头读取到字幕流位置截止,大概占文件大小的40%. 只有修改版 emby 客户端调用 MX Player 会同时传递所有外挂字幕,其余方式包括串流地址不支持外挂字幕加载,需要手动下载字幕文件并选择装载
146 | 2. ~~google Drive由于api的限制直链只能通过server中转,所以还是建议在cf上搭建goindex来获取直链 ,如何给到emby请参考 这篇[文章](https://blog.738888.xyz/2021/09/09/emby%E6%8C%82%E8%BD%BD%E7%BD%91%E7%9B%98%E8%BD%AC%E7%9B%B4%E9%93%BE%E6%92%AD%E6%94%BE/)结尾,另外一种方法是给alist添加cf worker中转gd的支持,有待研究~~
147 | alist 新版已经支持 cf worker 代理 gd 下载了,详情参考 alist 文档
148 | 3. 可能会有其他问题,请留言
149 |
--------------------------------------------------------------------------------
/emby2Alist/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.5'
2 | services:
3 | service.nginx-emby:
4 | image: nginx:1.27.1
5 | container_name: nginx-emby
6 | # 需要 Docker Swarm 模式
7 | # deploy:
8 | # resources:
9 | # limits:
10 | # cpus: '0.50'
11 | # memory: 128M
12 | # 更改为默认host网络,纠正流量标识,直接占用宿主机端口,docker层会禁用端口映射
13 | network_mode: host
14 | # 如果需要使用bridge(桥接)网络,请取消ports(端口映射)注释,并注释network_mode
15 | # 端口映射规则为,宿主机端口:容器内部端口
16 | # ports:
17 | # - 8091:8091
18 | # - 8095:8095
19 | # 如果遇到海报全部裂开或日志中 permission denied,以下二选一
20 | # 还不行的话,自己给宿主机 embyCache 读写权限
21 | # privileged: true
22 | # entrypoint: |
23 | # /bin/sh -c "chmod -R 777 /var/cache/nginx/emby"
24 | volumes:
25 | - ../nginx/nginx.conf:/etc/nginx/nginx.conf
26 | - ../nginx/conf.d:/etc/nginx/conf.d
27 | - ../nginx/embyCache:/var/cache/nginx/emby
28 | - ../nginx/log:/var/log/nginx
29 | restart: always
30 | # depends_on:
31 | # - service.ali
32 |
33 | # service.ali:
34 | # image: xhofe/alist:latest
35 | # container_name: alist
36 | # ports:
37 | # - 5244:5244
38 | # volumes:
39 | # - ../alist:/opt/alist/data
40 | # restart: always
41 |
42 | # service.ali-webdav:
43 | # image: messense/aliyundrive-webdav
44 | # container_name: ali-webdav
45 | # ports:
46 | # - 8080:8080
47 | # volumes:
48 | # - ./aliyundrive-webdav/:/etc/aliyundrive-webdav/
49 | # environment:
50 | # - REFRESH_TOKEN=1111111111111aaaaaaaaaa
51 | # - WEBDAV_AUTH_USER=admin
52 | # - WEBDAV_AUTH_PASSWORD=admin
53 | # restart: always
54 |
--------------------------------------------------------------------------------
/emby2Alist/docker/nginx-emby.syno.json:
--------------------------------------------------------------------------------
1 | {
2 | "CapAdd" : null,
3 | "CapDrop" : null,
4 | "cmd" : "nginx -g daemon\\ off\\;",
5 | "cpu_priority" : 50,
6 | "enable_publish_all_ports" : false,
7 | "enable_restart_policy" : true,
8 | "enabled" : false,
9 | "entrypoint_default" : "/docker-entrypoint.sh",
10 | "env_variables" : [
11 | {
12 | "key" : "TZ",
13 | "value" : "Asia/Shanghai"
14 | }
15 | ],
16 | "exporting" : false,
17 | "image" : "nginx:1.27.1",
18 | "is_ddsm" : false,
19 | "is_package" : false,
20 | "links" : [],
21 | // 128MB
22 | "memory_limit" : 134217728,
23 | "name" : "nginx-emby",
24 | // 更改为默认host网络,纠正流量标识,直接占用宿主机端口,docker层会禁用端口映射
25 | "network_mode" : "host",
26 | // "network_mode" : "bridge",
27 | // 如果需要使用bridge(桥接)网络,请取消ports(端口映射)注释,并修改network_mode为bridge
28 | // 端口映射规则为,host_port(宿主机端口),container_port(容器内部端口)
29 | "port_bindings" : [
30 | // {
31 | // "host_port" : 8091,
32 | // "container_port" : 8091,
33 | // "type" : "tcp"
34 | // },
35 | // {
36 | // "host_port" : 8095,
37 | // "container_port" : 8095,
38 | // "type" : "tcp"
39 | // }
40 | ],
41 | "privileged" : false,
42 | "shortcut" : {
43 | "enable_shortcut" : false,
44 | "enable_status_page" : false,
45 | "enable_web_page" : false,
46 | "web_page_url" : ""
47 | },
48 | // "use_host_network" : true,
49 | "volume_bindings" : [
50 | {
51 | "host_volume_file" : "/docker/nginx-emby/log",
52 | "mount_point" : "/var/log/nginx",
53 | "type" : "rw"
54 | },
55 | {
56 | "host_volume_file" : "/docker/nginx-emby/embyCache",
57 | "mount_point" : "/var/cache/nginx/emby",
58 | "type" : "rw"
59 | },
60 | {
61 | "host_volume_file" : "/docker/nginx-emby/config/conf.d",
62 | "mount_point" : "/etc/nginx/conf.d",
63 | "type" : "rw"
64 | },
65 | {
66 | "host_volume_file" : "/docker/nginx-emby/config/nginx.conf",
67 | "mount_point" : "/etc/nginx/nginx.conf",
68 | "type" : "rw"
69 | }
70 | ]
71 | }
72 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/api/alist-api.js:
--------------------------------------------------------------------------------
1 | // @author: chen3861229
2 | // @date: 2024-04-16
3 |
4 | // import util from "../common/util.js";
5 | import urlUtil from "../common/url-util.js";
6 |
7 | const API_ENUM = {
8 | fsGet: "/api/fs/get",
9 | fsLink: "/api/fs/link",
10 | fsList: "/api/fs/list",
11 | };
12 |
13 | async function fetchAuth(url, username, password) {
14 | const body = {
15 | username: username,
16 | password: password,
17 | };
18 | try {
19 | const response = await ngx.fetch(url, {
20 | method: "POST",
21 | max_response_body_size: 1024,
22 | body: JSON.stringify(body),
23 | });
24 | if (response.ok) {
25 | const result = await response.json();
26 | if (!result) {
27 | return `error: alist_auth_api response is null`;
28 | }
29 | if (result.message == "success") {
30 | return result.data.token;
31 | }
32 | return `error500: alist_auth_api ${result.code} ${result.message}`;
33 | } else {
34 | return `error: alist_auth_api ${response.status} ${response.statusText}`;
35 | }
36 | } catch (error) {
37 | return `error: alist_auth_api filed ${error}`;
38 | }
39 | }
40 |
41 | async function fetchPath(url, filePath, token, ua) {
42 | const requestBody = {
43 | path: filePath,
44 | password: "",
45 | };
46 | try {
47 | const urlParts = urlUtil.parseUrl(url);
48 | const hostValue = `${urlParts.host}:${urlParts.port}`;
49 | ngx.log(ngx.WARN, `fetchAlistPath add Host: ${hostValue}`);
50 | const response = await ngx.fetch(url, {
51 | method: "POST",
52 | headers: {
53 | "Content-Type": "application/json;charset=utf-8",
54 | Authorization: token,
55 | "User-Agent": ua,
56 | Host: hostValue,
57 | },
58 | max_response_body_size: 65535,
59 | body: JSON.stringify(requestBody),
60 | });
61 | if (response.ok) {
62 | const result = await response.json();
63 | if (!result) {
64 | return `error: alist_path_api response is null`;
65 | }
66 | if (result.message == "success") {
67 | return result;
68 | }
69 | if (result.code == 403) {
70 | return `error403: alist_path_api ${result.message}`;
71 | }
72 | return `error500: alist_path_api ${result.code} ${result.message}`;
73 | } else {
74 | return `error: alist_path_api ${response.status} ${response.statusText}`;
75 | }
76 | } catch (error) {
77 | return `error: alist_path_api fetchAlistFiled ${error}`;
78 | }
79 | }
80 |
81 | export default {
82 | API_ENUM,
83 | fetchAuth,
84 | fetchPath,
85 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/api/emby-api.js:
--------------------------------------------------------------------------------
1 | // @author: chen3861229
2 | // @date: 2024-03-31
3 |
4 | import config from "../constant.js";
5 | import urlUtil from "../common/url-util.js";
6 |
7 | const PLAY_METHOD_ENUM = {
8 | DirectPlay: "DirectPlay",
9 | DirectStream: "DirectStream",
10 | Transcode: "Transcode"
11 | };
12 |
13 | async function fetchNotificationsAdmin(Name, Description) {
14 | const body = {
15 | Name: Name,
16 | Description: Description
17 | }
18 | try {
19 | ngx.fetch(`${config.embyHost}/Notifications/Admin?api_key=${config.embyApiKey}`, {
20 | method: "POST",
21 | headers: {
22 | "Content-Type": "application/json;charset=utf-8"
23 | },
24 | body: JSON.stringify(body),
25 | }).then(res => {
26 | if (res.ok) {
27 | ngx.log(ngx.WARN, `success: fetchNotificationsAdmin: ${JSON.stringify(body)}`);
28 | } else {
29 | ngx.log(ngx.ERR, `error: fetchNotificationsAdmin: ${res.status} ${res.statusText}`);
30 | }
31 | });
32 | } catch (error) {
33 | ngx.log(ngx.ERR, `error: fetchNotificationsAdmin: ${error}`);
34 | }
35 | }
36 |
37 | async function fetchSessionsMessage(Id, Header, Text, TimeoutMs) {
38 | const body = {
39 | Header: Header,
40 | Text: Text,
41 | TimeoutMs: TimeoutMs,
42 | }
43 | try {
44 | ngx.fetch(`${config.embyHost}/Sessions/${Id}/Message?api_key=${config.embyApiKey}`, {
45 | method: "POST",
46 | headers: {
47 | "Content-Type": "application/json;charset=utf-8"
48 | },
49 | body: JSON.stringify(body),
50 | }).then(res => {
51 | if (res.ok) {
52 | ngx.log(ngx.WARN, `success: fetchSessionsMessage: ${JSON.stringify(body)}`);
53 | } else {
54 | ngx.log(ngx.ERR, `error: fetchSessionsMessage: ${res.status} ${res.statusText}`);
55 | }
56 | });
57 | } catch (error) {
58 | ngx.log(ngx.ERR, `error: fetchSessionsMessage: ${error}`);
59 | }
60 | }
61 |
62 | async function fetchSessions(host, apiKey, queryParams) {
63 | if (!host || !apiKey || !queryParams) {
64 | ngx.log(ngx.ERR, `error: fetchSessions: params is required`);
65 | return;
66 | }
67 | let url = `${host}/Sessions?api_key=${apiKey}`;
68 | for (const key in queryParams) {
69 | url = urlUtil.appendUrlArg(url, key, queryParams[key]);
70 | }
71 | return ngx.fetch(url, {
72 | method: "GET",
73 | headers: {
74 | "Content-Type": "application/json;charset=utf-8",
75 | "Accept-Encoding": "",
76 | },
77 | });
78 | }
79 |
80 | async function fetchPlaybackInfo(itemId) {
81 | return ngx.fetch(`${config.embyHost}/Items/${itemId}/PlaybackInfo?api_key=${config.embyApiKey}`, {
82 | method: "POST",
83 | headers: {
84 | "Content-Type": "application/json;charset=utf-8"
85 | }
86 | });
87 | }
88 |
89 | async function fetchItems(host, apiKey, queryParams) {
90 | if (!host || !apiKey || !queryParams) {
91 | ngx.log(ngx.ERR, `error: fetchItems: params is required`);
92 | return;
93 | }
94 | let url = `${host}/Items?api_key=${apiKey}`;
95 | for (const key in queryParams) {
96 | url = urlUtil.appendUrlArg(url, key, queryParams[key]);
97 | }
98 | ngx.log(ngx.WARN, `warn: fetchItems url: ${url}`);
99 | return ngx.fetch(url, {
100 | method: "GET",
101 | headers: {
102 | "Content-Type": "application/json;charset=utf-8",
103 | "Accept-Encoding": "",
104 | }
105 | });
106 | }
107 |
108 | async function fetchVideosActiveEncodingsDelete(host, apiKey, queryParams) {
109 | if (!host || !apiKey || !queryParams) {
110 | ngx.log(ngx.ERR, `error: fetchVideosActiveEncodingsDelete: params is required`);
111 | return;
112 | }
113 | let url = `${host}/Videos/ActiveEncodings?api_key=${apiKey}`;
114 | for (const key in queryParams) {
115 | url = urlUtil.appendUrlArg(url, key, queryParams[key]);
116 | }
117 | ngx.log(ngx.WARN, `warn: fetchVideosActiveEncodingsDelete url: ${url}`);
118 | return ngx.fetch(url, {
119 | method: "DELETE",
120 | headers: {
121 | "Content-Type": "application/json;charset=utf-8",
122 | "Accept-Encoding": "",
123 | }
124 | });
125 | }
126 |
127 | async function fetchBaseHtmlPlayer(host, queryParams) {
128 | let url = `${host}/web/modules/htmlvideoplayer/basehtmlplayer.js`;
129 | for (const key in queryParams) {
130 | url = urlUtil.appendUrlArg(url, key, queryParams[key]);
131 | if (key === "v") {
132 | ngx.log(ngx.WARN, `fetchBaseHtmlPlayer version: ${queryParams[key]}`);
133 | }
134 | }
135 | ngx.log(ngx.WARN, `fetchBaseHtmlPlayer url: ${url}`);
136 | return ngx.fetch(url);
137 | }
138 |
139 | export default {
140 | PLAY_METHOD_ENUM,
141 | fetchNotificationsAdmin,
142 | fetchSessionsMessage,
143 | fetchSessions,
144 | fetchPlaybackInfo,
145 | fetchItems,
146 | fetchVideosActiveEncodingsDelete,
147 | fetchBaseHtmlPlayer,
148 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/common/events.js:
--------------------------------------------------------------------------------
1 | // @author: Chen3861229
2 | // @date: 2024-04-19
3 | // NJS events
4 |
5 | function njsOnExit(mark, callbacks, r) {
6 | njsOnBeginNotice(mark);
7 | const eventName = "exit";
8 | if (callbacks && Array.isArray(callbacks)) {
9 | callbacks.map(callback => {
10 | njsOn(eventName, callback);
11 | });
12 | }
13 | njsOn(eventName, () => {
14 | njsOnExitNotice(mark);
15 | });
16 | }
17 |
18 | function njsOn(eventName, callback) {
19 | njs.on(eventName, callback);
20 | }
21 |
22 | function njsOnBeginNotice(mark) {
23 | ngx.log(ngx.WARN, `=== ${mark}, the NJS VM is beginning ===`);
24 | }
25 |
26 | function njsOnExitNotice(mark) {
27 | ngx.log(ngx.WARN, `=== ${mark}, the NJS VM is destroyed ===`);
28 | }
29 |
30 | export default {
31 | njsOnExit,
32 | njsOn,
33 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/common/live-util.js:
--------------------------------------------------------------------------------
1 | // @author: Chen3861229
2 | // @date: 2024-08-16
3 |
4 | const SUBS_CODEC_ENUM = {
5 | srt: "srt",
6 | ass: "ass",
7 | ssa: "ssa",
8 | subrip: "subrip",
9 | webvtt: "webvtt",
10 | };
11 |
12 | // extract audio, video and subtitles
13 | function parseM3U8(content) {
14 | const lines = content.split('\n').map(line => line.trim()).filter(line => line);
15 | const streams = [];
16 | const audios = [];
17 | const subtitles = [];
18 |
19 | for (let i = 0; i < lines.length; i++) {
20 | if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
21 | const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/);
22 | const resolutionMatch = lines[i].match(/RESOLUTION=\d+x(\d+)/);
23 | const nameMatch = lines[i].match(/NAME="([^"]+)"/);
24 | const url = lines[i + 1];
25 |
26 | if (bandwidthMatch && resolutionMatch && nameMatch && url) {
27 | // const bandwidth = bandwidthMatch[1];
28 | const bandwidth = parseInt(bandwidthMatch[1]) || 0;
29 | const resolution = resolutionMatch[1];
30 | const name = nameMatch[1];
31 | streams.push({
32 | bandwidth,
33 | resolution,
34 | url,
35 | quality: name
36 | });
37 | }
38 | } else if (lines[i].startsWith('#EXT-X-MEDIA:TYPE=AUDIO')) {
39 | const nameMatch = lines[i].match(/NAME="([^"]+)"/);
40 | const languageMatch = lines[i].match(/LANGUAGE="([^"]+)"/);
41 | const defaultMatch = lines[i].match(/DEFAULT=(YES|NO)/);
42 | const uriMatch = lines[i].match(/URI="([^"]+)"/);
43 |
44 | if (nameMatch && languageMatch && defaultMatch && uriMatch) {
45 | const name = nameMatch[1];
46 | const language = languageMatch[1];
47 | const isDefault = defaultMatch[1] === 'YES';
48 | const url = uriMatch[1];
49 | audios.push({
50 | name,
51 | language,
52 | isDefault,
53 | url
54 | });
55 | }
56 | }
57 | }
58 |
59 | return { streams, audios, subtitles };
60 | }
61 |
62 | function subCodecConvert(data, sourceCodec, targetCodec) {
63 | if (!targetCodec) { targetCodec = SUBS_CODEC_ENUM.webvtt; }
64 | let rvt = "";
65 | if (sourceCodec === SUBS_CODEC_ENUM.srt && targetCodec === SUBS_CODEC_ENUM.webvtt) {
66 | rvt = srt2webvtt(data);
67 | }
68 | return rvt;
69 | }
70 |
71 | // copy from https://github.com/silviapfeiffer/silviapfeiffer.github.io/blob/master/index.html
72 | // It's too complicated, it can be optimized
73 | function srt2webvtt(data) {
74 | // remove dos newlines, trim white space start and end
75 | const srt = data.replace(/\r+/g, '').replace(/^\s+|\s+$/g, '');
76 | // get cues
77 | const cuelist = srt.split('\n\n');
78 | let rvt = "";
79 | if (cuelist.length > 0) {
80 | rvt += "WEBVTT\n\n";
81 | for (let i = 0; i < cuelist.length; i = i + 1) {
82 | rvt += convertSrtCue(cuelist[i]);
83 | }
84 | }
85 | return rvt;
86 | }
87 |
88 | function convertSrtCue(caption) {
89 | let cue = "";
90 | const s = caption.split(/\n/);
91 | // concatenate muilt-line string separated in array into one
92 | while (s.length > 3) {
93 | for (let i = 3; i < s.length; i++) {
94 | s[2] += "\n" + s[i]
95 | }
96 | s.splice(3, s.length - 3);
97 | }
98 | let line = 0;
99 | // detect identifier
100 | if (!s[0].match(/\d+:\d+:\d+/) && s[1].match(/\d+:\d+:\d+/)) {
101 | cue += s[0].match(/\w+/) + "\n";
102 | line += 1;
103 | }
104 | // get time strings
105 | if (s[line].match(/\d+:\d+:\d+/)) {
106 | // convert time string
107 | const m = s[1].match(/(\d+):(\d+):(\d+)(?:,(\d+))?\s*--?>\s*(\d+):(\d+):(\d+)(?:,(\d+))?/);
108 | if (m) {
109 | cue += m[1]+":"+m[2]+":"+m[3]+"."+m[4]+" --> "
110 | +m[5]+":"+m[6]+":"+m[7]+"."+m[8]+"\n";
111 | line += 1;
112 | } else {
113 | // Unrecognized timestring
114 | return "";
115 | }
116 | } else {
117 | // file format error or comment lines
118 | return "";
119 | }
120 | // get cue text
121 | if (s[line]) {
122 | cue += s[line] + "\n\n";
123 | }
124 | return cue;
125 | }
126 |
127 | export default {
128 | SUBS_CODEC_ENUM,
129 | parseM3U8,
130 | subCodecConvert,
131 | }
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/common/periodics.js:
--------------------------------------------------------------------------------
1 | // @author: Chen3861229
2 | // @date: 2024-04-12
3 | // global schedule task
4 |
5 | // import config from "./constant.js";
6 | // import util from "./util.js";
7 |
8 | const fs = require("fs");
9 |
10 | async function logHandler(s) {
11 | const errorLogPath = ngx.error_log_path;
12 | if (!fs.existsSync(errorLogPath)) {
13 | return;
14 | }
15 | const timeLocal = s.variables["time_local"];
16 | // const dateLocal = s.variables["date_local"];
17 | fs.writeFileSync(errorLogPath, "");
18 | ngx.log(ngx.WARN, `cleared by periodics.logHandler`);
19 |
20 | const accessLogPath = errorLogPath.replace("error", "access");
21 | if (fs.existsSync(accessLogPath)) {
22 | fs.writeFileSync(accessLogPath, `${timeLocal}: js: cleared by periodics.logHandler\n`);
23 | }
24 | }
25 |
26 | export default {
27 | logHandler,
28 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/common/url-util.js:
--------------------------------------------------------------------------------
1 | // @author: Ambitious
2 | // @date: 2023-09-04
3 |
4 | function proxyUri(uri) {
5 | return `/proxy${uri}`;
6 | }
7 |
8 | function appendUrlArg(u, k, v) {
9 | if (u.includes(k)) {
10 | return u;
11 | }
12 | return u + (u.includes("?") ? "&" : "?") + `${k}=${v}`;
13 | }
14 |
15 | function generateUrl(r, host, uri, skipKeys) {
16 | skipKeys = skipKeys ?? [];
17 | let url = host + uri;
18 | let isFirst = true;
19 | for (const key in r.args) {
20 | if (skipKeys.includes(key)) {
21 | continue;
22 | }
23 | url += isFirst ? "?" : "&";
24 | url += `${key}=${r.args[key]}`;
25 | isFirst = false;
26 | }
27 | return url;
28 | }
29 |
30 | function addDefaultApiKey(r, u) {
31 | let url = u;
32 | if (!url.includes("api_key") && !url.includes("X-Emby-Token")) {
33 | url = appendUrlArg(url, "api_key", getDefaultApiKey(r.args));
34 | }
35 | return url;
36 | }
37 |
38 | function getCurrentRequestUrl(r) {
39 | return addDefaultApiKey(r, generateUrl(r, getCurrentRequestUrlPrefix(r), r.uri));
40 | }
41 |
42 | function getCurrentRequestUrlPrefix(r) {
43 | return `${r.variables.scheme}://${r.headersIn["Host"]}`;
44 | }
45 |
46 | function getDefaultApiKey(rArgs) {
47 | // emby old TV client only use config.embyApiKey
48 | const embyApiKey = config.embyApiKey;
49 | if (!rArgs) { return embyApiKey; }
50 | return rArgs["X-Emby-Token"] ?? (rArgs.api_key ?? embyApiKey);
51 | }
52 |
53 | function getDeviceId(r) {
54 | const rArgs = r.args;
55 | let deviceId = rArgs["X-Emby-Device-Id"];
56 | // jellyfin and old emby tv clients use DeviceId/deviceId
57 | if (!deviceId) {
58 | deviceId = rArgs["DeviceId"] || rArgs["deviceId"];
59 | }
60 | if (!deviceId) {
61 | deviceId = r.headersIn["DeviceId"];
62 | }
63 | return deviceId;
64 | }
65 |
66 | function getMediaSourceId(rArgs) {
67 | return rArgs.MediaSourceId ? rArgs.MediaSourceId : rArgs.mediaSourceId;
68 | }
69 |
70 | // r only is PlaybackInfo
71 | function generateDirectStreamUrl(r, mediaSourceId, resourceKey) {
72 | if (!resourceKey) { resourceKey = "stream."; }
73 | let directStreamUrl = addDefaultApiKey(
74 | r,
75 | generateUrl(r, "", r.uri, ["StartTimeTicks"])
76 | // official clients hava /emby web context path, like fileball not hava, both worked
77 | .replace(/^.*\/items/i, "/videos")
78 | .replace("PlaybackInfo", resourceKey)
79 | );
80 | directStreamUrl = appendUrlArg(
81 | directStreamUrl,
82 | "MediaSourceId",
83 | mediaSourceId
84 | );
85 | directStreamUrl = appendUrlArg(
86 | directStreamUrl,
87 | "Static",
88 | "true"
89 | );
90 | return directStreamUrl;
91 | }
92 |
93 | /**
94 | * 1.CloudDrive with params
95 | * http://mydomain:19798/static/http/mydomain:19798/False//AList/xxx.mkv?aaa=bbb
96 | * 2.AList with params
97 | * http://mydomain:5244/d/AList/xxx.mkv?aaa=bbb
98 | * see: https://regex101.com/r/Gd3JUH/2
99 | * @param {String} url full url
100 | * @returns "/AList/xxx.mkv" or "AList/xxx.mkv" or ""
101 | */
102 | function getFilePathPart(url) {
103 | const matches = url.match(/(?:\/False\/|\/d\/)(.*?)(?:\?|$)/);
104 | return matches ? matches[1] : "";
105 | }
106 |
107 | /**
108 | * Parses the URL and returns an object with various components.
109 | * @param {string} url The URL string to parse.
110 | * @returns {Object} An object containing protocol, username, password, host, port, pathname, search, and hash.
111 | */
112 | function parseUrl(url) {
113 | const regex = /^(?:(\w+)?:\/\/)?(?:(\w+):(\w+)@)?(?:www\.)?([^:\/\n?#]+)(?::(\d+))?(\/[^?\n]*)?(\?[^#\n]*)?(#.*)?$/i;
114 | const match = url.match(regex);
115 | if (match) {
116 | const protocol = match[1] || 'http';
117 | const username = match[2] || '';
118 | const password = match[3] || '';
119 | const host = match[4];
120 | const port = match[5] || '';
121 | const pathname = match[6] || '';
122 | const search = match[7] || '';
123 | const hash = match[8] || '';
124 | const fullProtocol = `${protocol}:`;
125 | const fullPort = port || (fullProtocol === 'https:' ? '443' : '80');
126 | return {
127 | protocol: fullProtocol,
128 | username,
129 | password,
130 | host,
131 | port: fullPort,
132 | pathname,
133 | search,
134 | hash
135 | };
136 | }
137 | return null;
138 | }
139 |
140 | function getRealIp(r) {
141 | const headers = r.headersIn;
142 | const ip = headers["X-Forwarded-For"] ||
143 | headers["X-Real-IP"] ||
144 | headers["Proxy-Client-IP"] ||
145 | headers["Proxy-Client-IP"] ||
146 | headers["WL-Proxy-Client-IP"] ||
147 | headers["HTTP_CLIENT_IP"] ||
148 | headers["HTTP_X_FORWARDED_FOR"] ||
149 | r.variables.remote_addr;
150 | return ip;
151 | }
152 |
153 | export default {
154 | proxyUri,
155 | appendUrlArg,
156 | generateUrl,
157 | addDefaultApiKey,
158 | getCurrentRequestUrl,
159 | getCurrentRequestUrlPrefix,
160 | getDefaultApiKey,
161 | getDeviceId,
162 | getMediaSourceId,
163 | generateDirectStreamUrl,
164 | getFilePathPart,
165 | parseUrl,
166 | getRealIp,
167 | }
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-common.js:
--------------------------------------------------------------------------------
1 |
2 | // 选填项,程序内部使用的公共常量
3 |
4 | // 字符串头,用于特殊匹配判断
5 | const strHead = {
6 | lanIp: ["172.", "10.", "192.", "[fd00:"], // 局域网ip头
7 | xEmbyClients: {
8 | seekBug: ["Emby for iOS"],
9 | },
10 | xUAs: {
11 | seekBug: ["Infuse", "VidHub", "SenPlayer"],
12 | clientsPC: ["EmbyTheater"],
13 | clients3rdParty: ["Fileball", "Infuse", "SenPlayer", "VidHub"],
14 | player3rdParty: ["dandanplay", "VLC", "MXPlayer", "PotPlayer"],
15 | blockDownload: ["Infuse-Download"],
16 | infuse: {
17 | direct: "Infuse-Direct",
18 | download: "Infuse-Download",
19 | },
20 | // 安卓与 TV 客户端不太好区分,浏览器 UA 关键字也有交叉重叠,请使用 xEmbyClients 参数或使用正则
21 | },
22 | "115": ["115.com", "115cdn.net"],
23 | ali: ["aliyundrive.net"],
24 | userIds: {
25 | mediaPathMappingGroup01: ["ac0d220d548f43bbb73cf9b44b2ddf0e"],
26 | allowInteractiveSearch: [],
27 | },
28 | filePaths: {
29 | mediaMountPath: [],
30 | redirectStrmLastLinkRule: [],
31 | mediaPathMappingGroup01: [],
32 | },
33 | };
34 |
35 | // 参数1: 分组名,组内为与关系(全部匹配),多个组和没有分组的规则是或关系(任一匹配)
36 | // 参数2: 匹配类型或来源(字符串参数类型),默认为 "filePath": 本地文件为路径,strm 为远程链接
37 | // ,有分组时不可省略填写,可为表达式
38 | // 参数3: 0: startsWith(str), 1: endsWith(str), 2: includes(str), 3: match(/ain/g)
39 | // ,分组时建议写 "startsWith" 这样的字符串,方便日志中排错
40 | // 参数4: 匹配目标,为数组的多个参数时,数组内为或关系(任一匹配)
41 | const ruleRef = {
42 | // 这个 key 值仅仅只是代码中引用的可读性标识,需见名知意,可自定义
43 | // mediaPathMappingGroup01: [
44 | // ["mediaPathMappingGroup01", "filePath", "startsWith", strHead.filePaths.mediaPathMappingGroup01], // 目标地址
45 | // ["mediaPathMappingGroup01", "r.args.X-Emby-Client", "startsWith:not", strHead.xEmbyClients.seekBug], // 链接入参,客户端类型
46 | // ["mediaPathMappingGroup01", "r.args.UserId", "startsWith", strHead.userIds.mediaPathMappingGroup01],
47 | // ],
48 | // directHlsEnable: [
49 | // // 此条规则代表大于等于 4Mbps 码率时生效,XMedia 为固定值,平方使用双星号表示
50 | // ["directHlsEnable", "r.XMedia.Bitrate", ">=", 4 * 1024 ** 2],
51 | // ["directHlsEnable", "r.args.UserId", "==", "ac0d220d548f43bbb73cf9b44b2ddf0e"],
52 | // ]
53 | };
54 |
55 | export default {
56 | strHead,
57 | ruleRef,
58 | }
59 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-ext.js:
--------------------------------------------------------------------------------
1 | import commonConfig from "./constant-common.js";
2 |
3 | const strHead = commonConfig.strHead;
4 | const ruleRef = commonConfig.ruleRef;
5 |
6 | // 选填项,特定平台功能,用不到保持默认即可
7 |
8 | // 图片缓存策略,包括主页、详情页、图片库的原图,路由器 nginx 请手动调小 conf 中 proxy_cache_path 的 max_size
9 | // 0: 不同尺寸设备共用一份缓存,先访问先缓存,空间占用最小但存在小屏先缓存大屏看的图片模糊问题
10 | // 1: 不同尺寸设备分开缓存,空间占用适中,命中率低下,但契合 emby 的图片缩放处理
11 | // 2: 不同尺寸设备共用一份缓存,空间占用最大,移除 emby 的缩放参数,直接原图高清显示
12 | // 3: 关闭 nginx 缓存功能,已缓存文件不做处理
13 | const imageCachePolicy = 0;
14 |
15 | // 对接 emby 通知管理员设置,目前只发送是否直链成功和屏蔽详情,依赖 emby/jellyfin 的 webhook 配置并勾选外部通知
16 | const embyNotificationsAdmin = {
17 | enable: false,
18 | includeUrl: false, // 链接太长,默认关闭
19 | name: "【emby2Alist】",
20 | };
21 |
22 | // 对接 emby 设备控制推送通知消息,目前只发送是否直链成功,此处为统一开关,范围为所有的客户端,通知目标只为当前播放的设备
23 | const embyRedirectSendMessage = {
24 | enable: false,
25 | header: "【emby2Alist】",
26 | timeoutMs: -1, // 消息通知弹窗持续毫秒值
27 | };
28 |
29 | // 按路径匹配规则隐藏部分接口返回的 items
30 | // 参数1: 0: startsWith(str), 1: endsWith(str), 2: includes(str), 3: match(/ain/g)
31 | // 参数2: 匹配目标,对象为 Item.Path
32 | // 参数3: 0: 默认同时过滤下列所有类型接口, 1: 只隐藏[搜索建议(不会过滤搜索接口)]接口,
33 | // 2: 只隐藏[更多类似(若当前浏览项目位于规则中,将跳过隐藏)]接口, 3: 只隐藏第三方使用的[海报推荐]接口
34 | // 4: 只隐藏[首页最新项目]接口,
35 | const itemHiddenRule = [
36 | // [0, "/mnt/sda1"],
37 | // [1, ".mp3", 1],
38 | // [2, "Google", 2],
39 | // [3, /private/ig],
40 | ];
41 |
42 | // 串流配置
43 | const streamConfig = {
44 | // 默认不启用,因违反 HTTP 规范,链接中携带未编译中文,可能存在兼容性问题,如发现串流访问失败,请关闭此选项,
45 | // !!! 谨慎开启,启用后将修改直接串流链接为真实文件名,方便第三方播放器友好显示和匹配,
46 | // 该选项只对 emby 有用, jellyfin 为前端自行拼接的
47 | useRealFileName: false,
48 | };
49 |
50 | // 搜索接口增强配置
51 | const searchConfig = {
52 | // 开启脚本的部分交互性功能
53 | interactiveEnable: false,
54 | // 快速交互,启用后将根据指令头匹配,直接返回虚拟搜索结果,不经过回源查询,优化搜索栏失焦的自动搜索
55 | interactiveFast: false,
56 | // 限定交互性功能的隔离,取值来源为带参数的 request_uri 字符串
57 | // 不带协议与域名,仅作包含匹配,多个值为或的关系,未定义或空数组为不隔离
58 | //interactiveEnableRule: [
59 | // "ac0d220d548f43bbb73cf9b44b2ddf0e", // request_uri path level userId
60 | // "2d427412-43e1-49e4-a1db-fa17c04d49db", // X-Emby-Device-Id
61 | //],
62 | };
63 |
64 | // 115网盘 web cookie, 会覆盖从 alist 获取到的 cookie
65 | const webCookie115 = "";
66 | // 网盘转码直链配置,当前仅支持 115(必填 webCookie115) 和 emby 挂载媒体环境
67 | const directHlsConfig = {
68 | enable: false,
69 | // 仅在首次占位未获取清晰度时,默认播放最小,开启后默认播放最大,版本缓存有效期内客户端自行选择
70 | defaultPlayMax: false,
71 | // 启用规则,仅在 enable = true 时生效
72 | enableRule: ruleRef.directHlsEnable ?? [],
73 | };
74 |
75 | // PlaybackInfo 接口的一些增强配置
76 | const playbackInfoConfig = {
77 | enabled: true,
78 | // 根据规则组指定播放源排序规则(与 redirectStrmLastLinkRule 配置类似,但必须设置分组名)
79 | // sourcesSortRules 为旧版兼容排序规则,同时做为未匹配的默认排序规则,不要使用这个组名
80 | // 匹配规则越靠前优先级越高
81 | // 参数1: 分组名,组内为与关系(全部匹配),排序规则 key 需要与分组名相同
82 | // 参数2: 匹配类型或来源(字符串参数类型),不支持 filePath 和 alistRes 变量
83 | // 参数3: 0: startsWith(str), 1: endsWith(str), 2: includes(str), 3: match(/ain/g)
84 | // 参数4: 匹配目标,为数组的多个参数时,数组内为或关系(任一匹配)
85 | sourcesSortFitRule: [
86 | // ["useGroup01", "r.variables.remote_addr", 0, strHead.lanIp], // 客户端为内网
87 | // ["useGroup01", "r.args.X-Emby-Client", "startsWith", strHead.xEmbyClients.seekBug], // Emby 客户端类型
88 | // ["useGroup02", "r.variables.remote_addr", "startsWith:not", strHead.lanIp[0]], // 公网
89 | // ["useGroup02", "r.variables.remote_addr", "startsWith:not", strHead.lanIp[3]], // 公网
90 | // ["useGroup03", "r.args.X-Emby-Client", 2, "Emby Web"], // Emby 客户端类型为浏览器
91 | // ["useGroup03", "r.headersIn.user-agent", 2, "Chrome"], // 通用客户端 UA 标识
92 | ],
93 | // 多版本播放源排序规则,对接口数据 MediaSources 数组进行排序,优先级从上至下,数组内从左至右,支持正则表达式
94 | // key 使用"."进行层级,分割后的键按层级从 MediaSources 获取,根据分割键获取下一层值时若对象为数组,则过滤[Type === 分割键]的第一行数据
95 | // (如: "MediaStreams.Video.Height"规则中 MediaSources.MediaStreams 值为数组,则取数组中[Type === "Video"]的对象的 Height 值)
96 | // ":length"为关键字,用于数组长度排序
97 | // value 只有三种类型, "asc": 正序, "desc": 倒序, 字符串/正则混合数组: 指定按关键字顺序排序
98 | // 非正则情况下, value 不区分大小写(简化书写), 只有正则区分大小写
99 | sourcesSortRules: {
100 | // "Path": ["1080p", "720p", "480p", "hevc", "h265", "h264"], // 按原文件名路径关键字排序
101 | // "Path": [/^\d{4}p$/g, /^\d{3}p$/g, "hevc", "h265", "h264"], // 正则匹配 4 位数字排在 3 位数字前面并忽略大小写
102 | // "MediaStreams.Video.Height": "desc", // 按视频高度倒序
103 | // "MediaStreams.Video.Codec": ["AV1", "HEVC", "H264"], // 按视频编码排序
104 | // "MediaStreams.Subtitle:length": "desc", // 更多字幕的排在前面
105 | // "MediaStreams.Video.BitRate": "asc", // 码率正序
106 | },
107 | // useGroup01: {
108 | // "MediaStreams.Video.Width": "desc",
109 | // "MediaStreams.Video.ExtendedVideoSubType": ["DoviProfile5", "DoviProfile8", "Hdr10", "DoviProfile7", "None"], // 视频编码子类型
110 | // "MediaStreams.Video.BitRate": "desc", // 码率倒序
111 | // "MediaStreams.Video.RealFrameRate": "desc", // 帧率倒序
112 | // },
113 | // useGroup02: {
114 | // "MediaStreams.Video.BitRate": "asc",
115 | // "MediaStreams.Video.RealFrameRate": "asc",
116 | // },
117 | // useGroup03: {
118 | // "MediaStreams.Video.VideoRange": ["SDR"], // 视频动态范围
119 | // },
120 | }
121 |
122 | export default {
123 | imageCachePolicy,
124 | embyNotificationsAdmin,
125 | embyRedirectSendMessage,
126 | itemHiddenRule,
127 | streamConfig,
128 | searchConfig,
129 | webCookie115,
130 | directHlsConfig,
131 | playbackInfoConfig,
132 | }
133 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-mount.js:
--------------------------------------------------------------------------------
1 | import commonConfig from "./constant-common.js";
2 |
3 | const strHead = commonConfig.strHead;
4 |
5 | // 选填项,用不到保持默认即可
6 |
7 | // rclone/CD2 挂载的 alist 文件配置,根据实际情况修改下面的设置
8 | // 访问宿主机上 5244 端口的 alist 地址, 要注意 iptables 给容器放行端口
9 | const alistAddr = "http://172.17.0.1:5244";
10 |
11 | // alist token, 在 alist 后台查看
12 | const alistToken = "alsit-123456";
13 |
14 | // alist 是否启用了 sign
15 | const alistSignEnable = false;
16 |
17 | // alist 中设置的直链过期时间,以小时为单位,严格对照 alist 设置 => 全局 => 直链有效期
18 | const alistSignExpireTime = 12;
19 |
20 | // alist 公网地址,用于需要 alist server 代理流量的情况,按需填写
21 | const alistPublicAddr = "http://youralist.com:5244";
22 |
23 | // 指定客户端自己请求并获取 alist 直链的规则,代码优先级在 redirectStrmLastLinkRule 之后
24 | // 特殊情况使用,则此处必须使用域名且公网畅通,用不着请保持默认
25 | // 参数?.1: 分组名,组内为与关系(全部匹配),多个组和没有分组的规则是或关系(任一匹配),然后下面参数序号-1
26 | // 参数?.2: 匹配类型或来源(字符串参数类型),优先级高"filePath": 文件路径(Item.Path),默认为"alistRes": alist 返回的链接 raw_url
27 | // ,有分组时不可省略填写,可为表达式,然后下面参数序号-1
28 | // 参数3: 0: startsWith(str), 1: endsWith(str), 2: includes(str), 3: match(/ain/g)
29 | // 参数4: 匹配目标,为数组的多个参数时,数组内为或关系(任一匹配)
30 | // 参数5: 指定转发给客户端的 alist 的 host 前缀,兼容 sign 参数
31 | const clientSelfAlistRule = [
32 | // Infuse 客户端对于 115 的进度条拖动可能依赖于此
33 | // 如果 nginx 为 https,则此 alist 也必须 https,浏览器行为客户端会阻止非 https 请求
34 | [2, strHead["115"], alistPublicAddr],
35 | // [2, strHead.ali, alistPublicAddr],
36 | // 优先使用 filePath,可省去一次查询 alist,如驱动为 alias,则应使用 alistRes
37 | // ["115-local", "filePath", 0, "/mnt/115", alistPublicAddr],
38 | // ["115-local", "r.args.X-Emby-Client", 0, strHead.xEmbyClients.seekBug], // 链接入参,客户端类型
39 | // ["115-alist", "alistRes", 2, strHead["115"], alistPublicAddr],
40 | // ["115-alist", "r.args.X-Emby-Client", 0, strHead.xEmbyClients.seekBug],
41 | ];
42 |
43 | // 响应重定向链接前是否检测有效性,无效链接时转给媒体服务器回源中转处理
44 | const redirectCheckEnable = false;
45 |
46 | // 媒体服务/alist 查询失败后是否使用原始链接回源中转流量处理,如无效则直接返回 500
47 | const fallbackUseOriginal = true;
48 |
49 | export default {
50 | alistAddr,
51 | alistToken,
52 | alistSignEnable,
53 | alistSignExpireTime,
54 | alistPublicAddr,
55 | clientSelfAlistRule,
56 | redirectCheckEnable,
57 | fallbackUseOriginal,
58 | }
59 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-nginx.js:
--------------------------------------------------------------------------------
1 | // import embySearch from "../modules/emby-search.js"; // this is cycle import, unhandled promise rejection: ReferenceError: cannot access variable before initialization
2 |
3 | // 选填项,用不到保持默认即可
4 |
5 | const nginxConfig = {
6 | // 禁用上游服务的 docs 页面
7 | disableDocs: true,
8 | };
9 |
10 | // for js_set
11 | function getDisableDocs(r) {
12 | const value = nginxConfig.disableDocs
13 | && !ngx.shared["tmpDict"].get("opendocs");
14 | // r.log(`getDisableDocs: ${value}`);
15 | return value;
16 | }
17 |
18 | export default {
19 | nginxConfig,
20 | getDisableDocs,
21 | }
22 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-pro.js:
--------------------------------------------------------------------------------
1 | import commonConfig from "./constant-common.js";
2 | import mountConfig from "./constant-mount.js";
3 |
4 | const strHead = commonConfig.strHead;
5 | const ruleRef = commonConfig.ruleRef;
6 | const alistPublicAddr = mountConfig.alistPublicAddr;
7 |
8 | // 选填项,高级配置,用不到保持默认即可
9 |
10 | // 重定向/直链开关配置,关闭的效果为还原为严格反代逻辑,即中转上游源服务流量
11 | // 此为粗颗粒度控制,优先级最高,细颗粒控制依旧使用路由规则管理
12 | const redirectConfig = {
13 | enable: true, // 允许直链的总开关,false 等同覆盖下列所有为 false
14 | // 允许电视直播直链,关闭后特例将忽略 transcodeConfig.enable 值,因直播 m3u 特殊表现为转码播放,实际并未占用服务端硬件转码
15 | enableVideoLivePlay: true,
16 | enableVideoStreamPlay: true, // 允许视频串流播放直链
17 | enableAudioStreamPlay: true, // 允许音频串流播放直链
18 | enableItemsDownload: true, // 允许网页下载项目直链
19 | enableSyncDownload: true, // 允许官方客户端下载项目直链
20 | };
21 |
22 | // 路由缓存配置
23 | const routeCacheConfig = {
24 | // 总开关,是否开启路由缓存,此为一级缓存,添加阶段为 redirect 和 proxy 之前
25 | // 短时间内同客户端访问相同资源不会再做判断和请求 alist,有限的防抖措施,出现问题可以关闭此选项
26 | enable: true,
27 | // 二级缓存开关,仅针对直链,添加阶段为进入单集详情页,clientSelfAlistRule 中的和首页直接播放的不生效
28 | // 非 web 端且限 UA 的不建议使用,效率太低,因部分客户端详情页 UA 和播放器 UA 存在不同的情况
29 | enableL2: false,
30 | // 缓存键表达式,默认值好处是命中范围大,但会导致 routeRule 中针对设备的规则失效,多个变量可自行组合修改,冒号分隔
31 | // 注意 jellyfin 是小写开头 mediaSourceId
32 | keyExpression: "r.uri:r.args.MediaSourceId", // "r.uri:r.args.MediaSourceId:r.args.X-Emby-Device-Id"
33 | };
34 |
35 | // 路由规则,注意顺序是从上至下匹配,千万注意规则不要重叠,不然排错十分困难,字幕和图片走了缓存,不在此规则内
36 | // 参数1: 指定处理模式,单规则的默认值为"proxy",但是注意整体规则都不匹配默认值为"redirect",然后下面参数序号-1
37 | // "proxy": 原始媒体服务器处理(中转流量), "redirect": 直链 302,
38 | // "transcode": 转码,稍微有些歧义,大部分情况等同于"proxy",这里只是不做转码参数修改,具体是否转码由 emby 客户端自己判断上报或客户端手动切换码率控制,
39 | // "block": 屏蔽媒体播放和下载, "blockDownload": 只屏蔽下载, "blockPlay": 只屏蔽播放,
40 | // 参数2: 分组名,组内为与关系(全部匹配),多个组和没有分组的规则是或关系(任一匹配),然后下面参数序号-1
41 | // 参数3: 匹配类型或来源(字符串参数类型) "filePath": 文件路径(Item.Path), "alistRes": alist返回的链接
42 | // 参数4: 0: startsWith(str), 1: endsWith(str), 2: includes(str), 3: match(/ain/g)
43 | // 参数5: 匹配目标,为数组的多个参数时,数组内为或关系(任一匹配)
44 | const routeRule = [
45 | // ["filePath", 0, "/mnt/sda1"],
46 | // ["filePath", 1, ".mp3"],
47 | // ["filePath", 2, "Google"],
48 | // ["alistRes", 2, "/NAS/"], // 例如使用 alias 聚合了 nas 本地文件,可能会存在卡顿或花屏
49 | // ["filePath", 3, /private/ig],
50 | // docker 注意必须为 host 模式,不然此变量全部为内网ip,判断无效,nginx 内置变量不带$,客户端地址($remote_addr)
51 | // ["r.variables.remote_addr", 0, strHead.lanIp],
52 | // ["r.headersIn.User-Agent", 2, "IE"], // 请求头参数,客户端UA
53 | // ["r.args.X-Emby-Device-Id", 0, "d4f30461-ec5c-488d-b04a-783e6f419eb1"], // 链接入参,设备id
54 | // ["r.args.X-Emby-Device-Name", 0, "Microsoft Edge Windows"], // 链接入参,设备名称
55 | // ["r.args.UserId", 0, "ac0d220d548f43bbb73cf9b44b2ddf0e"], // 链接入参,用户id
56 | // 注意非"proxy"无法使用"alistRes"条件,因为没有获取 alist 直链的过程
57 | // ["proxy", "filePath", 0, "/mnt/sda1"],
58 | // ["proxy", "直播走中转01", "r.XMedia.IsInfiniteStream", "===", true],
59 | // ["redirect", "filePath", 0, "/mnt/sda2"],
60 | // ["transcode", "filePath", 0, "/mnt/sda3"],
61 | // ["transcode", "115-local", "r.args.X-Emby-Client", 0, strHead.xEmbyClients.XXX],
62 | // ["transcode", "115-local", "filePath", 0, "/mnt/115"],
63 | // ["block", "filePath", 0, "/mnt/sda4"],
64 |
65 | // 高级分组规则,XMedia 为固定值,等于 Emby.MediaSources 数组中的单个目标对象
66 | // 注意设备id具有唯一性,不会跟随切换用户变更,取值参考 /PlaybackInfo 接口的出入参数
67 | // r.XMedia.MediaStreams 数组比较特殊,路由规则暂未做关键词抽取,但目前匹配视频流规则够用,多音频/字幕流不太好写规则
68 | // r.XMedia.MediaStreams.0 一般为视频流对象,不支持 r.XMedia.MediaStreams[0] 写法
69 |
70 | // 此条规则代表大于等于 3Mbps 码率的允许转码,平方使用双星号表示,无意义加减仅为示例,注意 emby/jellyfin 码率为 bps 单位
71 | // ["transcode", "高码率允许转码01", "r.XMedia.Bitrate", ">=", 3 * 1000 ** 2 + (1 * 1000 ** 2) - (1 * 1000 ** 2)],
72 | // 可选规则,结合上条规则做分组,同时满足才能生效,否则继续向下匹配
73 | // ["transcode", "高码率允许转码01", "r.args.X-Emby-Device-Id", "===", ["设备id01", "设备id02"]],
74 | // 此条规则代表 4K 分辨率的允许转码,但假如设备自身上报和上游决定走转码,不满足的也会转码,遵守上游倾向为播放成功率考虑
75 | // ["transcode", "高分辨率允许转码01", "r.XMedia.MediaStreams.0.DisplayTitle", "includes", "4K"],
76 | // 可选替换上条规则,更精确的分辨率规则,例如 21:9 视频,或某些 2.5 K 视频等不在标准分辨率划分内的
77 | // ["transcode", "高分辨率允许转码01", "r.XMedia.MediaStreams.0.Width", ">=", 4320],
78 | // 精确屏蔽指定功能,注意同样是整体规则都不匹配默认走"redirect",即不屏蔽,建议只用下方一条,太复杂的话需要自行测试
79 | // ["blockDownload", "屏蔽下载01", "r.headersIn.User-Agent", "includes", strHead.xUAs.blockDownload],
80 | // 非必须,该分组内细分为用户 id 白名单,结合上面一条代表 "屏蔽指定标识客户端的非指定用户的下载"
81 | // ["blockDownload", "屏蔽下载01", "r.args.UserId", "startsWith:not", ["用户id01", "用户id02"]],
82 | // 非必须,该分组内细分为入库路径黑名单,结合上面两条代表 "屏蔽指定标识客户端的非指定用户的指定入库路径的下载"
83 | // ["blockDownload", "屏蔽下载01", "filePath", "startsWith", ["/mnt/115"]],
84 | ];
85 |
86 | // 路径映射,会在 mediaMountPath 之后从上到下依次全部替换一遍,不要有重叠,注意 /mnt 会先被移除掉了
87 | // 参数?.1: 生效规则三维数组,有时下列参数序号加一,优先级在参数2之后,需同时满足,多个组是或关系(任一匹配)
88 | // 参数1: 0: 默认做字符串替换 replace 一次, 1: 前插, 2: 尾插, 3: replaceAll 替换全部
89 | // 参数2: 0: 默认只处理本地路径且不为 strm, 1: 只处理 strm 内部为/开头的相对路径, 2: 只处理 strm 内部为远程链接的, 3: 全部处理
90 | // 参数3: 来源, 参数4: 目标
91 | const mediaPathMapping = [
92 | // [0, 0, "/aliyun-01", "/aliyun-02"],
93 | // [0, 2, "http:", "https:"],
94 | // [0, 2, ":5244", "/alist"],
95 | // [0, 0, "D:", "F:"],
96 | // [0, 0, /blue/g, "red"], // 此处正则不要加引号
97 | // [1, 1, `${alistPublicAddr}/d`],
98 | // [2, 2, "?xxx"],
99 | // 此条是一个规则变量引用,方便将规则汇合到同一处进行管理
100 | // [ruleRef.mediaPathMappingGroup01, 0, 0, "/aliyun-01", "/aliyun-02"],
101 | // 路径映射多条规则会从上至下依次执行,如下有同一个业务关系集的,注意带上区间的闭合条件,不然会被后续重复替换会覆盖
102 | // 以下是按码率条件进行路径映射,全用户设备强制,区分用户和设备可再精确添加条件
103 | // [[["4K 目录映射到 1080P 目录", "r.XMedia.Bitrate", ">", 10 * 1000 ** 2],
104 | // ], 0, 0, "/4K/", "/1080P/"],
105 | // [[["1080P 目录映射到 720P 目录", "r.XMedia.Bitrate", ">", 6 * 1000 ** 2],
106 | // ["1080P 目录映射到 720P 目录", "r.XMedia.Bitrate", "<=", 10 * 1000 ** 2],
107 | // ], 0, 0, "/1080P/", "/720P/"],
108 | // [[["720P 目录映射到 480P 目录", "r.XMedia.Bitrate", ">", 3 * 1000 ** 2],
109 | // ["720P 目录映射到 480P 目录", "r.XMedia.Bitrate", "<=", 6 * 1000 ** 2],
110 | // ], 0, 0, "/720P/", "/480P/"],
111 | ];
112 |
113 | // 仅针对 alist 返回的 raw_url 进行路径映射,优先级在 mediaPathMapping 和 clientSelfAlistRule 后,使用方法一样
114 | // 参数?.1: 生效规则三维数组,有时下列参数序号加一,优先级在参数2之后,需同时满足,多个组是或关系(任一匹配)
115 | // 参数1: 0: 默认做字符串替换replace一次, 1: 前插, 2: 尾插, 3: replaceAll替换全部
116 | // 参数2: 0: 默认只处理本地路径且不为 strm, 1: 只处理 strm 内部为/开头的相对路径, 2: 只处理 strm 内部为远程链接的, 3: 全部处理
117 | // 参数3: 来源, 参数4: 目标
118 | const alistRawUrlMapping = [
119 | // [0, 0, "/alias/movies", "/aliyun-01"],
120 | ];
121 |
122 | export default {
123 | redirectConfig,
124 | routeCacheConfig,
125 | routeRule,
126 | mediaPathMapping,
127 | alistRawUrlMapping,
128 | }
129 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-strm.js:
--------------------------------------------------------------------------------
1 | import commonConfig from "./constant-common.js";
2 | import mountConfig from "./constant-mount.js";
3 |
4 | const strHead = commonConfig.strHead;
5 | const alistAddr = mountConfig.alistAddr;
6 | const alistToken = mountConfig.alistToken;
7 | const alistSignExpireTime = mountConfig.alistSignExpireTime;
8 |
9 | // 只使用 strm 文件配置模板,即标准 strm 内部只有远程链接,不存在/开头的相对路径
10 | // 不需要挂载功能,不显示依赖 alist,strm 内部为任意直链
11 |
12 | // 选填项,用不到保持默认即可
13 |
14 | // 指定是否转发由 njs 获取 strm/远程链接 重定向后直链地址的规则,例如 strm/远程链接 内部为局域网 ip 或链接需要验证
15 | // 匹配来源为入库媒体的文件路径
16 | // 参数?.1: 分组名,组内为与关系(全部匹配),多个组和没有分组的规则是或关系(任一匹配),然后下面参数序号-1
17 | // 参数?.2: 匹配类型或来源(字符串参数类型),默认为 "filePath": mediaPathMapping 映射后的 strm/远程链接 内部链接
18 | // ,有分组时不可省略填写,可为表达式
19 | // 参数3: 0: startsWith(str), 1: endsWith(str), 2: includes(str), 3: match(/ain/g)
20 | // 参数4: 匹配目标,为数组的多个参数时,数组内为或关系(任一匹配)
21 | const redirectStrmLastLinkRule = [
22 | [0, strHead.lanIp.map(s => "http://" + s)],
23 | // [0, alistAddr],
24 | // [0, "http:"],
25 | // 参数5: 请求验证类型,当前 alistAddr 不需要此参数
26 | // 参数6: 当前 alistAddr 不需要此参数,alistSignExpireTime
27 | // [3, "http://otheralist1.com", "sign", `${alistToken}:${alistSignExpireTime}`],
28 | // useGroup01 同时满足才命中
29 | // ["useGroup01", "filePath", "startsWith", strHead.lanIp.map(s => "http://" + s)], // 目标地址
30 | // ["useGroup01", "r.args.X-Emby-Client", "startsWith:not", strHead.xEmbyClients.seekBug], // 链接入参,客户端类型
31 | // docker 注意必须为 host 模式,不然此变量全部为内网ip,判断无效,nginx 内置变量不带$,客户端地址($remote_addr)
32 | // ["useGroup01", "r.variables.remote_addr", 0, strHead.lanIp], // 远程客户端为内网
33 | ];
34 |
35 | export default {
36 | redirectStrmLastLinkRule,
37 | }
38 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-symlink.js:
--------------------------------------------------------------------------------
1 |
2 | // 选填项,用不到保持默认即可
3 |
4 | // 指定需要获取符号链接真实路径的规则,优先级在 mediaMountPath 和 routeRule 之间
5 | // 注意前提条件是此程序或容器必须挂载或具有对应目录的读取权限,否则将跳过处理,回源中转
6 | // 此参数仅在软链接后的文件名和原始文件名不一致或路径差异较大时使用,其余情况建议用 mediaPathMapping
7 | // 参数1: 0: startsWith(str), 1: endsWith(str), 2: includes(str), 3: match(/ain/g)
8 | // 参数2: 匹配目标,对象为媒体服务入库的文件路径(Item.Path)
9 | const symlinkRule = [
10 | // [0, "/mnt/sda1"],
11 | ];
12 |
13 | export default {
14 | symlinkRule,
15 | }
16 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/config/constant-transcode.js:
--------------------------------------------------------------------------------
1 |
2 | // 选填项,用不到保持默认即可
3 |
4 | // 转码配置,默认 false,将按之前逻辑禁止转码处理并移除转码选项参数,与服务端允许转码配置有相关性
5 | const transcodeConfig = {
6 | enable: false, // 此大多数情况下为允许转码的总开关
7 | enableStrmTranscode: false, // 默认禁用 strm 的转码,体验很差,仅供调试使用
8 | type: "distributed-media-server", // 负载类型,可选值, ["nginx", "distributed-media-server"]
9 | maxNum: 3, // 单机最大转码数量,有助于加速轮询, 参数暂无作用,接口无法查询转码情况,忽略此参数
10 | redirectTransOptEnable: true, // 是否保留码率选择,不保留官方客户端将无法手动切换至转码
11 | targetItemMatchFallback: "redirect", // 目标服务媒体匹配失败后的降级后路由措施,可选值, ["redirect", "proxy"]
12 | // 如果只需要当前服务转码,enable 改为 true,server 改为下边的空数组
13 | server: [],
14 | // !!!实验功能,主库和所有从库给用户开启[播放-如有必要,在媒体播放期间允许视频转码]+[倒数7行-允许媒体转换]
15 | // type: "nginx", nginx 负载均衡,好处是使用简单且内置均衡参数选择,缺点是流量全部经过此服务器,
16 | // 且使用条件很苛刻,转码服务组中的媒体 id 需要和主媒体库中 id 一致,自行寻找实现主从同步,完全同步后,ApiKey 也是一致的
17 | // type: "distributed-media-server", 分布式媒体服务负载均衡(暂未实现均衡),优先利用 302 真正实现流量的 LB,且灵活,
18 | // 不区分主从,当前访问服务即为主库,可 emby/jellyfin 混搭,挂载路径可以不一致,但要求库中的标题和语种一致且原始文件名一致
19 | // 负载的服务组,需要分离转码时才使用,注意下列 host 必须全部为公网地址,会 302 给客户端访问,若参与负载下边手动添加
20 | // server: [
21 | // {
22 | // type: "emby",
23 | // host: "http://yourdomain.com:8096",
24 | // apiKey: "f839390f50a648fd92108bc11ca6730a",
25 | // },
26 | // {
27 | // type: "jellyfin",
28 | // host: "http://yourdomain.com:8097",
29 | // apiKey: "f839390f50a648fd92108bc11ca6730a",
30 | // },
31 | // ]
32 | };
33 |
34 | export default {
35 | transcodeConfig,
36 | }
37 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/constant.js:
--------------------------------------------------------------------------------
1 | // 如果使用拆分配置,请注意填写 config 下使用到的配置文件
2 |
3 | import commonConfig from "./config/constant-common.js";
4 | import mountConfig from "./config/constant-mount.js";
5 | import proConfig from "./config/constant-pro.js";
6 | import symlinkConfig from "./config/constant-symlink.js";
7 | import strmConfig from "./config/constant-strm.js";
8 | import transcodeConfig from "./config/constant-transcode.js";
9 | import extConfig from "./config/constant-ext.js";
10 | import nginxConfig from "./config/constant-nginx.js";
11 |
12 | // 必填项,根据实际情况修改下面的设置
13 |
14 | // 这里默认 emby/jellyfin 的地址是宿主机,要注意 iptables 给容器放行端口
15 | const embyHost = "http://172.17.0.1:8096";
16 |
17 | // emby/jellyfin api key, 在 emby/jellyfin 后台设置
18 | const embyApiKey = "f839390f50a648fd92108bc11ca6730a";
19 |
20 | // 挂载工具 rclone/CD2 多出来的挂载目录, 例如将 od,gd 挂载到 /mnt 目录下: /mnt/onedrive /mnt/gd ,那么这里就填写 /mnt
21 | // 通常配置一个远程挂载根路径就够了,默认非此路径开头文件将转给原始 emby 处理
22 | // 如果没有挂载,全部使用 strm 文件,此项填[""],必须要是数组
23 | const mediaMountPath = ["/mnt"];
24 |
25 | // for js_set
26 | function getEmbyHost(r) {
27 | return embyHost;
28 | }
29 | function getTranscodeEnable(r) {
30 | return transcodeConfig.transcodeConfig.enable;
31 | }
32 | function getTranscodeType(r) {
33 | return transcodeConfig.transcodeConfig.type;
34 | }
35 | function getImageCachePolicy(r) {
36 | return extConfig.imageCachePolicy;
37 | }
38 |
39 | function getUsersItemsLatestFilterEnable(r) {
40 | return extConfig.itemHiddenRule.some(rule => !rule[2] || rule[2] == 0 || rule[2] == 4);
41 | }
42 |
43 | export default {
44 | embyHost,
45 | embyApiKey,
46 | mediaMountPath,
47 | strHead: commonConfig.strHead,
48 |
49 | alistAddr: mountConfig.alistAddr,
50 | alistToken: mountConfig.alistToken,
51 | alistSignEnable: mountConfig.alistSignEnable,
52 | alistSignExpireTime: mountConfig.alistSignExpireTime,
53 | alistPublicAddr: mountConfig.alistPublicAddr,
54 | clientSelfAlistRule: mountConfig.clientSelfAlistRule,
55 | redirectCheckEnable: mountConfig.redirectCheckEnable,
56 | fallbackUseOriginal: mountConfig.fallbackUseOriginal,
57 |
58 | redirectConfig: proConfig.redirectConfig,
59 | routeCacheConfig: proConfig.routeCacheConfig,
60 | routeRule: proConfig.routeRule,
61 | mediaPathMapping: proConfig.mediaPathMapping,
62 | alistRawUrlMapping: proConfig.alistRawUrlMapping,
63 |
64 | symlinkRule: symlinkConfig.symlinkRule,
65 | redirectStrmLastLinkRule: strmConfig.redirectStrmLastLinkRule,
66 | transcodeConfig: transcodeConfig.transcodeConfig,
67 |
68 | embyNotificationsAdmin: extConfig.embyNotificationsAdmin,
69 | embyRedirectSendMessage: extConfig.embyRedirectSendMessage,
70 | itemHiddenRule: extConfig.itemHiddenRule,
71 | streamConfig: extConfig.streamConfig,
72 | searchConfig: extConfig.searchConfig,
73 | webCookie115: extConfig.webCookie115,
74 | directHlsConfig: extConfig.directHlsConfig,
75 | playbackInfoConfig: extConfig.playbackInfoConfig,
76 |
77 | getEmbyHost,
78 | getTranscodeEnable,
79 | getTranscodeType,
80 | getImageCachePolicy,
81 | getUsersItemsLatestFilterEnable,
82 |
83 | nginxConfig: nginxConfig.nginxConfig,
84 | getDisableDocs: nginxConfig.getDisableDocs,
85 | }
86 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/custome/location/default.conf:
--------------------------------------------------------------------------------
1 |
2 | # 1.location 默认为精确匹配,优先级最高;
3 | # 2."^~": 前缀匹配,不支持正则,优先级高于正则匹配,低于精确匹配;
4 | # 3."~": 区分大小写路径正则匹配,优先级最低;
5 | # 4."~*": 不区分大小写路径正则匹配,优先级最低;
6 | # 5.请求路径带 / 结尾: 匹配目录,自动追加斜杠; 不带 / 结尾: 匹配请求或文件或目录,不自动追加斜杠;
7 |
8 | # location ~* /Sync/JobItems/(.*)/File {
9 | # }
10 |
11 | # 切断客户端与上游服务器的 304 缓存协商
12 | # location ~* \.(js|css)$ {
13 | # proxy_set_header Cache-Control "no-cache";
14 | # add_header X-Mark "forced Cache-Control no-cache";
15 | # proxy_pass $emby;
16 | # }
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/docs/UA.txt:
--------------------------------------------------------------------------------
1 | // Warnning
2 | 01. undefined
3 |
4 | // Android Mobile Clients
5 | 01. Emby/3.2.32-17.41 (Linux;Android 14) ExoPlayerLib/2.13.2
6 | 02. Emby/3.2.32-17.24 (Linux;Android 13) ExoPlayerLib/2.13.2
7 | 03. libmpv
8 |
9 | // Android TV Clients
10 | 01. Emby/2.0.95g (Linux;Android 9) ExoPlayerLib/2.18.7
11 |
12 | // Windows PC Clients
13 | 01. Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EmbyTheater/3.0.20-3.0 Chrome/100.0.4896.160 Electron/18.3.15 Safari/537.36
14 |
15 | // 3rd party Clients
16 | 01. Fileball/238 CFNetwork/1410.0.3 Darwin/22.6.0
17 | 02. Infuse-Direct/7.8
18 | 03. Infuse-Download/8.0.2
19 | 04. SenPlayer/4.0.8
20 | 05. VidHub/1.7.6
21 |
22 | // 3rd party Player
23 | 01. dandanplay/android 4.1.0
24 | 02. VLC/3.0.18 LibVLC/3.0.18
25 | 03. VLC/4.0.0-dev LibVLC/4.0.0-dev
26 | 04. MXPlayer/1.68.4 (Linux; Android 14; zh-CN; 2311DRK48C Build/UP1A.230905.011)
27 | 05. (Windows NT 10.0; Win64; x64) PotPlayer/23.7.7
28 |
29 | // Web Browsers
30 | 01. Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0
31 | 02. Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0
32 | 03. AppleCoreMedia/1.0.0.20G81 (iPad; U; CPU OS 16_6_1 like Mac OS X; zh_cn)
33 | 04. AppleCoreMedia/1.0.0.21F90 (iPhone; U; CPU OS 17_5_1 like Mac OS X; zh_cn)
34 | 05. Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.200720.011; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/89.0.4389.72 MQQBrowser/6.2 TBS/045913 Mobile Safari/537.36 V1_AND_SQ_8.8.68_2538_YYB_D A_8086800 QQ/8.8.68.7265 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/76 SimpleUISwitch/1 QQTheme/2971 InMagicWin/0 StudyMode/0 CurrentMode/1 CurrentFontScale/1.0 GlobalDensityScale/0.9818182 AppId/537112567 Edg/98.0.4758.102
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/docs/test-data.md:
--------------------------------------------------------------------------------
1 |
2 | # emby-v-media.js
3 |
4 | #### 1.fetch115Hls masterPlaylistText
5 | ```log
6 | #EXTM3U
7 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1800000,RESOLUTION=1280x720,AUDIO="Audio-Group",NAME="HD"
8 | https: //cpats01.115.com/2032827273f2509ec953cdcdf6bb2643/66BDE8C8/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148_1280.m3u8?u=361327067&se=u,ua&s=104857600&ck=
9 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,RESOLUTION=1920x1080,AUDIO="Audio-Group",NAME="UD"
10 | https: //cpats01.115.com/7b9da42ee9b6ef6d88b9cdc216b52f4a/66BDE8C8/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148_1920.m3u8?u=361327067&se=u,ua&s=157286400&ck=
11 | ```
12 |
13 | #### 2.fetch115Hls function result
14 | ```json
15 | {
16 | "streams": [
17 | {
18 | "resolution": "720",
19 | "url": "https://cpats01.115.com/2809f2d1eef77d71beab670be626d566/66BE4AC2/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148_1280.m3u8?u=361327067&se=u,ua&s=104857600&ck=",
20 | "quality": "HD"
21 | },
22 | {
23 | "resolution": "1080",
24 | "url": "https://cpats01.115.com/b3a68e1971bd46c58725c86228fde500/66BE4AC2/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148_1920.m3u8?u=361327067&se=u,ua&s=157286400&ck=",
25 | "quality": "UD"
26 | }
27 | ],
28 | "audios": [],
29 | "subtitles": [
30 | {
31 | "sid": "41f2287f2634e9d8762104691de0c974builtin",
32 | "language": "",
33 | "title": "[内置字幕]简体",
34 | "url": "http://cpats01.115.com/43579af238305bcbec6af0df14d71e71/66BE4AC2/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148_0.srt?u=0&t=33db561f2eca147c51b77f3b8dc2f18f",
35 | "type": "srt"
36 | },
37 | {
38 | "sid": "f2ac38e1122f83fa13f378abba2bc185builtin",
39 | "language": "",
40 | "title": "[内置字幕]繁体",
41 | "url": "http://cpats01.115.com/716ea709a65a436c7ad9c0eaecbb2aa8/66BE4AC2/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148/6CC0BBC7CEFCD1AA339F9CE64BE82E4249321148_1.srt?u=0&t=33db561f2eca147c51b77f3b8dc2f18f",
42 | "type": "srt"
43 | }
44 | ],
45 | }
46 | ```
47 |
48 | #### 3.fetch115Hls subtitle API result
49 | ```json
50 | {
51 | "state": true,
52 | "error": "",
53 | "errNo": 0,
54 | "data": {
55 | "autoload": {
56 | "sid": "18df1b2ceef91541d3bc82c7a10c4249builtin",
57 | "language": "",
58 | "title": "[内置字幕]字幕1",
59 | "url": "http://cpats01.115.com/397c53d085d9804be5a35f60e10871d6/66BF8DB2/83C82F4C00A1349F53298119F408F6FAA6C5D678/83C82F4C00A1349F53298119F408F6FAA6C5D678_0.ass?u=0&t=33db561f2eca147c51b77f3b8dc2f18f",
60 | "type": "ass"
61 | },
62 | "list": [
63 | {
64 | "sid": "18df1b2ceef91541d3bc82c7a10c4249builtin",
65 | "language": "",
66 | "title": "[内置字幕]字幕1",
67 | "url": "http://cpats01.115.com/397c53d085d9804be5a35f60e10871d6/66BF8DB2/83C82F4C00A1349F53298119F408F6FAA6C5D678/83C82F4C00A1349F53298119F408F6FAA6C5D678_0.ass?u=0&t=33db561f2eca147c51b77f3b8dc2f18f",
68 | "type": "ass"
69 | },
70 | {
71 | "sid": "5ac9fb583be99c63415862771e2080fabuiltin",
72 | "language": "",
73 | "title": "[内置字幕]字幕2",
74 | "url": "http://cpats01.115.com/b0e55d33463c239e9d5bd2049ac2ccd1/66BF8DB2/83C82F4C00A1349F53298119F408F6FAA6C5D678/83C82F4C00A1349F53298119F408F6FAA6C5D678_1.ass?u=0&t=33db561f2eca147c51b77f3b8dc2f18f",
75 | "type": "ass"
76 | }
77 | ]
78 | }
79 | }
80 | ```
81 |
82 | #### 4.emby local media External subtitle type item
83 | ```json
84 | {
85 | "Protocol": "File",
86 | "Id": "442550c9fb8501bf2e8d317495a04d26",
87 | "Path": "/AList/alias/xxx - S01E01 - 第1集.mkv",
88 | "Type": "Default",
89 | "Container": "mkv",
90 | "Size": 1091315571,
91 | "Name": "xxx - S01E01 - 第1集",
92 | "IsRemote": false,
93 | "HasMixedProtocols": false,
94 | "RunTimeTicks": 14119520000,
95 | "SupportsTranscoding": false,
96 | "SupportsDirectStream": true,
97 | "SupportsDirectPlay": true,
98 | "IsInfiniteStream": false,
99 | "RequiresOpening": false,
100 | "RequiresClosing": false,
101 | "RequiresLooping": false,
102 | "SupportsProbing": false,
103 | "MediaStreams": [
104 | {
105 | "Codec": "hevc",
106 | "TimeBase": "1/1000",
107 | "VideoRange": "SDR",
108 | "DisplayTitle": "1080p HEVC",
109 | "IsInterlaced": false,
110 | "BitRate": 6183301,
111 | "BitDepth": 10,
112 | "RefFrames": 1,
113 | "IsDefault": true,
114 | "IsForced": false,
115 | "IsHearingImpaired": false,
116 | "Height": 1080,
117 | "Width": 1920,
118 | "AverageFrameRate": 23.976025,
119 | "RealFrameRate": 23.976025,
120 | "Profile": "Main 10",
121 | "Type": "Video",
122 | "AspectRatio": "16:9",
123 | "Index": 0,
124 | "IsExternal": false,
125 | "IsTextSubtitleStream": false,
126 | "SupportsExternalStream": false,
127 | "Protocol": "File",
128 | "PixelFormat": "yuv420p10le",
129 | "Level": 120,
130 | "IsAnamorphic": false,
131 | "ExtendedVideoType": "None",
132 | "ExtendedVideoSubType": "None",
133 | "ExtendedVideoSubTypeDescription": "None",
134 | "AttachmentSize": 0
135 | },
136 | {
137 | "Codec": "flac",
138 | "TimeBase": "1/1000",
139 | "DisplayTitle": "FLAC stereo (默认)",
140 | "IsInterlaced": false,
141 | "ChannelLayout": "stereo",
142 | "BitDepth": 24,
143 | "Channels": 2,
144 | "SampleRate": 48000,
145 | "IsDefault": true,
146 | "IsForced": false,
147 | "IsHearingImpaired": false,
148 | "Type": "Audio",
149 | "Index": 1,
150 | "IsExternal": false,
151 | "IsTextSubtitleStream": false,
152 | "SupportsExternalStream": false,
153 | "Protocol": "File",
154 | "ExtendedVideoType": "None",
155 | "ExtendedVideoSubType": "None",
156 | "ExtendedVideoSubTypeDescription": "None",
157 | "AttachmentSize": 0
158 | },
159 | {
160 | "Codec": "ass",
161 | "Language": "chs",
162 | "DisplayTitle": "Chinese Simplified (ASS)",
163 | "DisplayLanguage": "Chinese Simplified",
164 | "IsInterlaced": false,
165 | "IsDefault": false,
166 | "IsForced": false,
167 | "IsHearingImpaired": false,
168 | "Type": "Subtitle",
169 | "Index": 2,
170 | "IsExternal": true,
171 | "DeliveryMethod": "External",
172 | "DeliveryUrl": "/Videos/284773/442550c9fb8501bf2e8d317495a04d26/Subtitles/2/0/Stream.ass?api_key=xxx",
173 | "IsExternalUrl": false,
174 | "IsTextSubtitleStream": true,
175 | "SupportsExternalStream": true,
176 | "Path": "/AList/alias/xxx - S01E01 - 第1集.chi.zh-cn.ass",
177 | "Protocol": "File",
178 | "ExtendedVideoType": "None",
179 | "ExtendedVideoSubType": "None",
180 | "ExtendedVideoSubTypeDescription": "None",
181 | "AttachmentSize": 0
182 | },
183 | {
184 | "Codec": "ass",
185 | "Language": "cht",
186 | "DisplayTitle": "Chinese Traditional (ASS)",
187 | "DisplayLanguage": "Chinese Traditional",
188 | "IsInterlaced": false,
189 | "IsDefault": false,
190 | "IsForced": false,
191 | "IsHearingImpaired": false,
192 | "Type": "Subtitle",
193 | "Index": 3,
194 | "IsExternal": true,
195 | "DeliveryMethod": "External",
196 | "DeliveryUrl": "/Videos/284773/442550c9fb8501bf2e8d317495a04d26/Subtitles/3/0/Stream.ass?api_key=xxx",
197 | "IsExternalUrl": false,
198 | "IsTextSubtitleStream": true,
199 | "SupportsExternalStream": true,
200 | "Path": "/AList/alias/xxx - S01E01 - 第1集.zh-tw.ass",
201 | "Protocol": "File",
202 | "ExtendedVideoType": "None",
203 | "ExtendedVideoSubType": "None",
204 | "ExtendedVideoSubTypeDescription": "None",
205 | "AttachmentSize": 0
206 | }
207 | ],
208 | "Formats": [],
209 | "Bitrate": 6183301,
210 | "RequiredHttpHeaders": {},
211 | "DirectStreamUrl": "/videos/284773/stream/xxx-%20S01E01%20-%20%E7%AC%AC1%E9%9B%86.mkv?UserId=ac0d220d548f43bbb73cf9b44b2ddf0e&IsPlayback=false&AutoOpenLiveStream=false&MaxStreamingBitrate=200000000&X-Emby-Client=Emby%20Web&X-Emby-Device-Name=Microsoft%20Edge%20Windows&X-Emby-Device-Id=2d427412-43e1-49e4-a1db-fa17c04d49db&X-Emby-Client-Version=4.8.8.0&X-Emby-Token=xxx&X-Emby-Language=zh-cn&reqformat=json&MediaSourceId=442550c9fb8501bf2e8d317495a04d26&PlaySessionId=51c79c1d3e504cc6a8cf2688700963fa&Static=true",
212 | "AddApiKeyToDirectStreamUrl": false,
213 | "ReadAtNativeFramerate": false,
214 | "DefaultAudioStreamIndex": 1,
215 | "DefaultSubtitleStreamIndex": 2,
216 | "ItemId": "284773",
217 | "XRouteMode": "redirect",
218 | "XOriginDirectStreamUrl": "/videos/284773/original.mkv?DeviceId=2d427412-43e1-49e4-a1db-fa17c04d49db&MediaSourceId=442550c9fb8501bf2e8d317495a04d26&PlaySessionId=51c79c1d3e504cc6a8cf2688700963fa&api_key=xxx",
219 | "XModifyDirectStreamUrlSuccess": true
220 | }
221 | ```
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/exampleConfig/constant-main.js:
--------------------------------------------------------------------------------
1 | // 这个总配置拆分主体文件只是备份,生效需要放置在 conf.d 下,且重命名为 constant.js
2 | // 如果使用拆分配置,请注意填写 config 下的使用到的配置文件
3 |
4 | import commonConfig from "./config/constant-common.js";
5 | import mountConfig from "./config/constant-mount.js";
6 | import proConfig from "./config/constant-pro.js";
7 | import symlinkConfig from "./config/constant-symlink.js";
8 | import strmConfig from "./config/constant-strm.js";
9 | import transcodeConfig from "./config/constant-transcode.js";
10 | import extConfig from "./config/constant-ext.js";
11 | import nginxConfig from "./config/constant-nginx.js";
12 |
13 | // 必填项,根据实际情况修改下面的设置
14 |
15 | // 这里默认 emby/jellyfin 的地址是宿主机,要注意 iptables 给容器放行端口
16 | const embyHost = "http://172.17.0.1:8096";
17 |
18 | // emby/jellyfin api key, 在 emby/jellyfin 后台设置
19 | const embyApiKey = "f839390f50a648fd92108bc11ca6730a";
20 |
21 | // 挂载工具 rclone/CD2 多出来的挂载目录, 例如将 od,gd 挂载到 /mnt 目录下: /mnt/onedrive /mnt/gd ,那么这里就填写 /mnt
22 | // 通常配置一个远程挂载根路径就够了,默认非此路径开头文件将转给原始 emby 处理
23 | // 如果没有挂载,全部使用 strm 文件,此项填[""],必须要是数组
24 | const mediaMountPath = ["/mnt"];
25 |
26 | // for js_set
27 | function getEmbyHost(r) {
28 | return embyHost;
29 | }
30 | function getTranscodeEnable(r) {
31 | return transcodeConfig.transcodeConfig.enable;
32 | }
33 | function getTranscodeType(r) {
34 | return transcodeConfig.transcodeConfig.type;
35 | }
36 | function getImageCachePolicy(r) {
37 | return extConfig.imageCachePolicy;
38 | }
39 |
40 | function getUsersItemsLatestFilterEnable(r) {
41 | return extConfig.itemHiddenRule.some(rule => !rule[2] || rule[2] == 0 || rule[2] == 4);
42 | }
43 |
44 | export default {
45 | embyHost,
46 | embyApiKey,
47 | mediaMountPath,
48 | strHead: commonConfig.strHead,
49 |
50 | alistAddr: mountConfig.alistAddr,
51 | alistToken: mountConfig.alistToken,
52 | alistSignEnable: mountConfig.alistSignEnable,
53 | alistSignExpireTime: mountConfig.alistSignExpireTime,
54 | alistPublicAddr: mountConfig.alistPublicAddr,
55 | clientSelfAlistRule: mountConfig.clientSelfAlistRule,
56 | redirectCheckEnable: mountConfig.redirectCheckEnable,
57 | fallbackUseOriginal: mountConfig.fallbackUseOriginal,
58 |
59 | routeCacheConfig: proConfig.routeCacheConfig,
60 | routeRule: proConfig.routeRule,
61 | mediaPathMapping: proConfig.mediaPathMapping,
62 | alistRawUrlMapping: proConfig.alistRawUrlMapping,
63 |
64 | symlinkRule: symlinkConfig.symlinkRule,
65 | redirectStrmLastLinkRule: strmConfig.redirectStrmLastLinkRule,
66 | transcodeConfig: transcodeConfig.transcodeConfig,
67 |
68 | embyNotificationsAdmin: extConfig.embyNotificationsAdmin,
69 | embyRedirectSendMessage: extConfig.embyRedirectSendMessage,
70 | itemHiddenRule: extConfig.itemHiddenRule,
71 | streamConfig: extConfig.streamConfig,
72 | searchConfig: extConfig.searchConfig,
73 | webCookie115: extConfig.webCookie115,
74 | directHlsConfig: extConfig.directHlsConfig,
75 | playbackInfoConfig: extConfig.playbackInfoConfig,
76 |
77 | getEmbyHost,
78 | getTranscodeEnable,
79 | getTranscodeType,
80 | getImageCachePolicy,
81 | getUsersItemsLatestFilterEnable,
82 |
83 | nginxConfig: nginxConfig.nginxConfig,
84 | getDisableDocs: nginxConfig.getDisableDocs,
85 | }
86 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/includes/http.conf:
--------------------------------------------------------------------------------
1 | server_name default;
2 | listen 8091; ## Listens on port IPv4
3 | listen [::]:8091; # Listens on port IPv6
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/includes/https.conf:
--------------------------------------------------------------------------------
1 | server_name default;
2 | ## SSL SETTINGS ##
3 | listen [::]:8095 ssl; ## Listens on port IPv6 with ssl enabled
4 | listen 8095 ssl; ## Listens on port IPv4 with ssl enabled
5 | # listen 8095 quic reuseport; ## http3 enabled
6 | http2 on; ## since nginx 1.25.1, the "listen ... http" directive is deprecated
7 | add_header Alt-Svc 'h3=":$server_port"; ma=86400'; ## http3 enabled
8 | ssl_session_timeout 30m;
9 | ssl_protocols TLSv1.3 TLSv1.2 TLSv1.1 TLSv1;
10 | ssl_certificate /etc/nginx/conf.d/cert/fullchain.pem; ## Location of your public PEM file.
11 | ssl_certificate_key /etc/nginx/conf.d/cert/privkey.key; ## Location of your private PEM file.
12 | ssl_session_cache shared:SSL:10m;
13 | error_page 497 =307 https://$host:$server_port$request_uri; ## if http and https use same port, Redirects http:// to https://
14 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/includes/proxy-header.conf:
--------------------------------------------------------------------------------
1 | #proxy_set_header X-Real-IP $http_CF_Connecting_IP;
2 | ## if you use cloudflare un-comment this line and comment out above line.
3 | proxy_set_header Host $host;
4 | proxy_set_header X-Real-IP $remote_addr;
5 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
6 | proxy_set_header X-Forwarded-Proto $scheme;
7 | proxy_set_header X-Forwarded-Protocol $scheme;
8 | proxy_set_header X-Forwarded-Host $http_host;
9 | proxy_set_header Range $http_range;
10 | proxy_set_header If-Range $http_if_range;
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/includes/server-group.conf:
--------------------------------------------------------------------------------
1 | upstream transcode_group {
2 | least_conn;
3 | server 172.17.0.1:8096;
4 | # server 172.17.0.2:8096;
5 | # server 172.17.0.3:8096;
6 | }
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/emby-example.js:
--------------------------------------------------------------------------------
1 | // @author: chen3861229
2 | // @date: 2024-07-13
3 |
4 | import config from "../constant.js";
5 | import util from "../common/util.js";
6 | import events from "../common/events.js";
7 | import emby from "../emby.js";
8 | import embyApi from "../api/emby-api.js";
9 |
10 | async function example() {
11 | return "Hello Word!";
12 | }
13 |
14 | export default {
15 | example,
16 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/emby-items.js:
--------------------------------------------------------------------------------
1 | // @author: chen3861229
2 | // @date: 2024-07-13
3 |
4 | import config from "../constant.js";
5 | import util from "../common/util.js";
6 | import urlUtil from "../common/url-util.js";
7 | import events from "../common/events.js";
8 | import emby from "../emby.js";
9 | // import embyApi from "../api/emby-api.js";
10 |
11 | async function itemsFilter(r) {
12 | events.njsOnExit(`itemsFilter: ${r.uri}`);
13 |
14 | const subRes = await subrequestForPath(r);
15 | const body = subRes.body;
16 | const subR = subRes.subR;
17 | const itemHiddenRule = config.itemHiddenRule.filter(rule => rule[2] != 4);
18 | if (itemHiddenRule && itemHiddenRule.length > 0) {
19 | const apiType = r.variables.apiType;
20 | r.warn(`itemsFilter apiType: ${apiType}`);
21 | let mainItemPath;
22 | if (apiType == "itemSimilar") {
23 | // fetch mount emby/jellyfin file path
24 | const itemInfo = util.getItemInfo(r);
25 | r.warn(`itemSimilarInfoUri: ${itemInfo.itemInfoUri}`);
26 | const embyRes = await util.cost(emby.fetchEmbyFilePath,
27 | itemInfo.itemInfoUri,
28 | itemInfo.itemId,
29 | itemInfo.Etag,
30 | itemInfo.mediaSourceId
31 | );
32 | mainItemPath = embyRes.path;
33 | r.warn(`mainItemPath: ${mainItemPath}`);
34 | }
35 |
36 | const beforeLength = body.Items.length;
37 | let itemHiddenCount = 0;
38 | if (body.Items) {
39 | body.Items = body.Items.filter(item => {
40 | if (!item.Path) {
41 | return true;
42 | }
43 | const flag = !itemHiddenRule.some(rule => {
44 | if ((!rule[2] || rule[2] == 0 || rule[2] == 2) && !!mainItemPath
45 | && util.strMatches(rule[0], mainItemPath, rule[1])) {
46 | return false;
47 | }
48 | if (apiType == "searchSuggest" && rule[2] == 2) {
49 | return false;
50 | }
51 | if (apiType == "backdropSuggest" && rule[2] == 3) {
52 | return false;
53 | }
54 | // 4: 只隐藏[类型风格]接口,这个暂时分页有 bug,被隐藏掉的项会有个空的海报,第一页后的 StartIndex 需要减去 itemHiddenCount
55 | // 且最重要是无法得知当前浏览项目,会误伤导致接口返回[],不建议实现该功能
56 | // if (apiType == "genreSearch" && rule[2] == 4) {
57 | // return false;
58 | // }
59 | if (apiType == "itemSimilar" && rule[2] == 1) {
60 | return false;
61 | }
62 | if (util.strMatches(rule[0], item.Path, rule[1])) {
63 | r.warn(`itemPath hit itemHiddenRule: ${item.Path}`);
64 | itemHiddenCount++;
65 | return true;
66 | }
67 | });
68 | delete item.Path;
69 | return flag;
70 | });
71 | }
72 | const logLevel = itemHiddenCount > 0 ? ngx.WARN : ngx.INFO;
73 | ngx.log(logLevel, `itemsFilter before: ${beforeLength}`);
74 | ngx.log(logLevel, `itemsFilter after: ${body.Items.length}`);
75 | if (body.TotalRecordCount) {
76 | body.TotalRecordCount -= itemHiddenCount;
77 | ngx.log(logLevel, `itemsFilter TotalRecordCount: ${body.TotalRecordCount}`);
78 | }
79 | }
80 |
81 | util.copyHeaders(subR.headersOut, r.headersOut);
82 | return r.return(200, JSON.stringify(body));
83 | }
84 |
85 | async function usersItemsLatestFilter(r) {
86 | events.njsOnExit(`usersItemsLatestFilter: ${r.uri}`);
87 |
88 | const subRes = await subrequestForPath(r);
89 | let body = subRes.body;
90 | const subR = subRes.subR;
91 | const itemHiddenRule = config.itemHiddenRule.filter(rule => !rule[2] || rule[2] == 0 || rule[2] == 4);
92 | if (itemHiddenRule && itemHiddenRule.length > 0 && Array.isArray(body)) {
93 | const beforeLength = body.length;
94 | body = body.filter(item => {
95 | if (!item.Path) {
96 | return true;
97 | }
98 | const flag = !itemHiddenRule.some(rule => {
99 | if (util.strMatches(rule[0], item.Path, rule[1])) {
100 | r.warn(`itemPath hit itemHiddenRule: ${item.Path}`);
101 | return true;
102 | }
103 | });
104 | delete item.Path;
105 | return flag;
106 | });
107 | const itemHiddenCount = beforeLength - body.length;
108 | const logLevel = itemHiddenCount > 0 ? ngx.WARN : ngx.INFO;
109 | ngx.log(logLevel, `usersItemsLatestFilter before: ${beforeLength}`);
110 | ngx.log(logLevel, `usersItemsLatestFilter after: ${body.length}`);
111 | ngx.log(logLevel, `usersItemsLatestFilter itemHiddenCount: ${itemHiddenCount}`);
112 | }
113 |
114 | util.copyHeaders(subR.headersOut, r.headersOut);
115 | return r.return(200, JSON.stringify(body));
116 | }
117 |
118 | async function subrequestForPath(r) {
119 | r.variables.request_uri += "&Fields=Path";
120 | // urlUtil.appendUrlArg(r.variables.request_uri, "Fields", "Path");
121 | const subR = await r.subrequest(urlUtil.proxyUri(r.uri), {
122 | method: r.method,
123 | });
124 | if (subR.status === 200) {
125 | const body = JSON.parse(subR.responseText);
126 | return { body, subR };
127 | } else {
128 | r.warn(`${r.uri} subrequest failed, status: ${subR.status}`);
129 | return emby.internalRedirect(r);
130 | }
131 | }
132 |
133 | export default {
134 | itemsFilter,
135 | usersItemsLatestFilter,
136 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/emby-live.js:
--------------------------------------------------------------------------------
1 | // @author: Ambitious
2 | // @date: 2023-09-04
3 |
4 | import config from "../constant.js";
5 | import util from "../common/util.js";
6 | import events from "../common/events.js";
7 | import Emby from "../emby.js";
8 | import embyVMedia from "../modules/emby-v-media.js";
9 |
10 | async function directLive(r) {
11 | events.njsOnExit(`directLive: ${r.uri}`);
12 |
13 | // check virtualMediaSources, versionDict cache range > routeLXDict, so ignore routeLXDict
14 | const vMediaUrl = await embyVMedia.getUrlByVMediaSources(r);
15 | // if (vMediaUrl === 1) {
16 | // ngx.log(ngx.WARN, `fetchHlsWithCache success, but pre IsPlayback true not ready Streams Array`);
17 | // return r.return(500, "need retry playback");
18 | // // return r.return(449, "need retry playback");
19 | // } else
20 | if (vMediaUrl) {
21 | return Emby.redirect(r, vMediaUrl);
22 | }
23 |
24 | if (!Emby.allowRedirect(r)) {
25 | return Emby.internalRedirect(r);
26 | }
27 |
28 | const embyHost = config.embyHost;
29 | const itemInfo = util.getItemInfo(r);
30 | // 1 get the ItemId
31 | const itemId = itemInfo.itemId;
32 | // 2 get Item's PlayBackInfo
33 | // 3 get the live-tv direct m3u8 url
34 | const itemInfoUri = `${embyHost}/Items/${itemId}/PlaybackInfo?api_key=${itemInfo.api_key}&AutoOpenLiveStream=true`;
35 | r.warn(`directLive itemInfoUri: ${itemInfoUri}`);
36 | const response = await ngx.fetch(itemInfoUri, {
37 | method: "POST",
38 | headers: {
39 | "Content-Type": "application/json;charset=utf-8"
40 | }
41 | });
42 | if (!response.ok) {
43 | r.error(response.statusText);
44 | return Emby.internalRedirect(r);
45 | }
46 | const body = await response.json();
47 | if (!body.MediaSources || body.MediaSources.length === 0) {
48 | r.error('no media source found');
49 | return Emby.internalRedirect(r);
50 | }
51 | if (!body.MediaSources[0].IsRemote) {
52 | // not a remote link
53 | return Emby.redirect2Pan(r);
54 | }
55 | // 5 execute redirect
56 | Emby.redirect(r, body.MediaSources[0].Path);
57 | }
58 |
59 | export default { directLive };
60 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/emby-playback-info.js:
--------------------------------------------------------------------------------
1 | // @author: ykchenc
2 | // @date: 2024-10-18
3 |
4 | function sourcesSort(mediaSources, rules) {
5 | return mediaSources.sort((a, b) => {
6 | for (let key in rules) {
7 | let ruleVal = rules[key];
8 | let aVal = getNestedValue(a, key);
9 | let bVal = getNestedValue(b, key);
10 | if (aVal === undefined) {
11 | if (bVal === undefined) continue;
12 | return 1;
13 | }
14 | if (bVal === undefined) return -1;
15 | if (typeof aVal === 'string') aVal = aVal.toLowerCase();
16 | if (typeof bVal === 'string') bVal = bVal.toLowerCase();
17 | if (Array.isArray(ruleVal)) {
18 | for (let i in ruleVal) {
19 | let rule = ruleVal[i];
20 | const hasRuleA = rule instanceof RegExp ? aVal.match(rule) : aVal.includes(rule.toLowerCase());
21 | const hasRuleB = rule instanceof RegExp ? bVal.match(rule) : bVal.includes(rule.toLowerCase());
22 | if (hasRuleA && !hasRuleB) return -1;
23 | if (!hasRuleA && hasRuleB) return 1;
24 | }
25 | } else {
26 | if (aVal < bVal) { return ruleVal === 'asc' ? -1 : 1; }
27 | if (aVal > bVal) { return ruleVal === 'asc' ? 1 : -1; }
28 | }
29 | }
30 | return 0;
31 | });
32 | }
33 | function getNestedValue(obj, path) {
34 | let current = obj;
35 | const keys = path.split('.');
36 | for (let i in keys) {
37 | let key = keys[i];
38 | if (key.includes(':length')) {
39 | const type = key.split(':')[0];
40 | return (current || []).filter(stream => stream.Type === type).length;
41 | }
42 | if (Array.isArray(current)) {
43 | current = current.find(item => item.Type === key);
44 | if (current === undefined) {
45 | return undefined;
46 | }
47 | } else if (current && current.hasOwnProperty(key)) {
48 | current = current[key];
49 | } else {
50 | return undefined;
51 | }
52 | }
53 | return current;
54 | }
55 |
56 | export default {
57 | sourcesSort,
58 | };
59 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/emby-search.js:
--------------------------------------------------------------------------------
1 | // @author: Chen3861229
2 | // @date: 2024-07-13
3 |
4 | import config from "../constant.js";
5 | import util from "../common/util.js";
6 | import events from "../common/events.js";
7 | import emby from "../emby.js";
8 | // import embyApi from "../api/emby-api.js";
9 |
10 | const ARGS = {
11 | dictName: "tmpDict",
12 | itemsType: "Episode",
13 | }
14 |
15 | const DIRECTIVE_SPLIT_ENUM = {
16 | directive: { key: "/", desc: "指令分隔符" },
17 | valueSplit: { key: "=", desc: "参数分隔符" },
18 | timeSplit: { key: ":", desc: "有效秒数分隔符" },
19 | }
20 |
21 | const DICT_ZONE_EMUN = {
22 | transcodeDict: { key: "transcodeDict", desc: "转码链接缓存字典" },
23 | routeL1Dict: { key: "routeL1Dict", desc: "一级直链缓存字典" },
24 | routeL2Dict: { key: "routeL2Dict", desc: "二级直链缓存字典" },
25 | // routeL3Dict: { key: "routeL3Dict", desc: "三级直链缓存字典" },
26 | idemDict: { key: "idemDict", desc: "防抖缓存字典" },
27 | tmpDict: { key: "tmpDict", desc: "内部用临时变量缓存字典" },
28 | versionDict: { key: "versionDict", desc: "虚拟多版本缓存字典" },
29 | }
30 |
31 | const DIRECTIVE_KEY_ENUM = {
32 | help: { key: "help", desc: "帮助" },
33 | skipImageCache: { key: "skipImageCache", desc: "临时跳过 nginx 图片缓存" },
34 | opendocs: { key: "opendocs", desc: "临时允许访问上游 docs" },
35 | showDictZoneState: { key: "showDictZoneState", desc: "显示路由缓存/直链缓存数量" },
36 | clearDictZone: { key: "clearDictZone", desc: "清空路由缓存/直链缓存" },
37 | }
38 |
39 | async function searchHandle(r) {
40 | const searchConfig = config.searchConfig;
41 | const enable = searchConfig && searchConfig.interactiveEnable;
42 | if (!enable) {
43 | r.variables.request_uri += `&${util.ARGS.useProxyKey}=1`;
44 | return emby.internalRedirectExpect(r, r.variables.request_uri);
45 | }
46 | let hitFlag = true;
47 | if (searchConfig.interactiveEnableRule && searchConfig.interactiveEnableRule.length > 0) {
48 | hitFlag = searchConfig.interactiveEnableRule.some(rule => r.variables.request_uri.includes(rule));
49 | }
50 | if (!hitFlag) {
51 | r.variables.request_uri += `&${util.ARGS.useProxyKey}=1`;
52 | return emby.internalRedirectExpect(r, r.variables.request_uri);
53 | }
54 |
55 | events.njsOnExit(`searchHandle: ${r.uri}`);
56 |
57 | const searchTerm = r.args.SearchTerm;
58 | r.headersOut['Content-Type'] = 'application/json; charset=utf-8';
59 | if (searchTerm.startsWith(DIRECTIVE_SPLIT_ENUM.directive.key + DIRECTIVE_KEY_ENUM.help.key)) {
60 | r.return(200, handleHelp(r));
61 | } else if (searchTerm.startsWith(DIRECTIVE_SPLIT_ENUM.directive.key + DIRECTIVE_KEY_ENUM.skipImageCache.key)) {
62 | r.return(200, handleWithTimeout(r, searchTerm, DIRECTIVE_KEY_ENUM.skipImageCache.key));
63 | } else if (searchTerm.startsWith(DIRECTIVE_SPLIT_ENUM.directive.key + DIRECTIVE_KEY_ENUM.opendocs.key)) {
64 | r.return(200, handleWithTimeout(r, searchTerm, DIRECTIVE_KEY_ENUM.opendocs.key));
65 | } else if (searchTerm.startsWith(DIRECTIVE_SPLIT_ENUM.directive.key + DIRECTIVE_KEY_ENUM.showDictZoneState.key)) {
66 | r.return(200, handleShowDictZoneStat(r, searchTerm));
67 | } else if (searchTerm.startsWith(DIRECTIVE_SPLIT_ENUM.directive.key + DIRECTIVE_KEY_ENUM.clearDictZone.key)) {
68 | r.return(200, handleClearDictZone(r, searchTerm));
69 | } else if (searchConfig.interactiveFast && searchTerm.startsWith(DIRECTIVE_SPLIT_ENUM.directive.key)) {
70 | r.return(200, handleHelp(r));
71 | } else {
72 | r.variables.request_uri += `&${util.ARGS.useProxyKey}=1`;
73 | return emby.internalRedirectExpect(r, r.variables.request_uri);
74 | }
75 | }
76 |
77 | function handleHelp(r) {
78 | const items1 = Object.keys(DIRECTIVE_KEY_ENUM).map(k => {
79 | return {
80 | Name: DIRECTIVE_KEY_ENUM[k].desc, // sub title
81 | SeriesName: DIRECTIVE_SPLIT_ENUM.directive.key + DIRECTIVE_KEY_ENUM[k].key, // main title
82 | Type: ARGS.itemsType,
83 | };
84 | });
85 | const items2 = Object.keys(DIRECTIVE_SPLIT_ENUM).map(k => {
86 | return {
87 | Name: DIRECTIVE_SPLIT_ENUM[k].desc, // sub title
88 | SeriesName: DIRECTIVE_SPLIT_ENUM[k].key, // main title
89 | Type: ARGS.itemsType,
90 | };
91 | });
92 | const items3 = Object.keys(DICT_ZONE_EMUN).map(k => {
93 | return {
94 | Name: DICT_ZONE_EMUN[k].desc, // sub title
95 | SeriesName: DICT_ZONE_EMUN[k].key, // main title
96 | Type: ARGS.itemsType,
97 | };
98 | });
99 | const items = items1.concat(items2).concat(items3);
100 | return JSON.stringify({
101 | Items: items,
102 | TotalRecordCount: 0
103 | });
104 | }
105 |
106 | /**
107 | * handleWithTimeout
108 | * @param {Object} r nginx objects, HTTP Request
109 | * @param {String} searchTerm
110 | * @param {String} directiveKey not with /
111 | * @returns
112 | */
113 | function handleWithTimeout(r, searchTerm, directiveKey) {
114 | let msg;
115 | let timeoutS = parseInt(searchTerm.split(DIRECTIVE_SPLIT_ENUM.timeSplit.key)[1]);
116 | r.warn(`${directiveKey} input timeoutS: ${timeoutS}`);
117 | if (isNaN(timeoutS)) {
118 | timeoutS = 60;
119 | r.warn(`${directiveKey} handle default timeoutS: ${timeoutS}`);
120 | }
121 | if (timeoutS < 1) {
122 | msg = ngx.shared[ARGS.dictName].delete(directiveKey) ? "CloseSuccess" : "CloseFail";
123 | } else {
124 | const msgIndex = util.dictAdd(ARGS.dictName, directiveKey, "1", timeoutS ? timeoutS * 1000 : -1);
125 | msg = ["AddFail", "NotExpire", `With60Seconds`, `With${timeoutS}Seconds`][msgIndex + 1];
126 | }
127 | r.warn(`${directiveKey} msg: ${msg}`);
128 | return JSON.stringify({
129 | Items: [{
130 | Name: searchTerm,
131 | SeriesName: msg,
132 | Type: ARGS.itemsType,
133 | }],
134 | TotalRecordCount: 0
135 | });
136 | }
137 |
138 | function handleShowDictZoneStat(r, searchTerm) {
139 | const items = Object.values(DICT_ZONE_EMUN).map(value => {
140 | return {
141 | Name: `${value.key}(${value.desc})`, // sub title
142 | SeriesName: String(ngx.shared[value.key].size()), // main title
143 | Type: ARGS.itemsType,
144 | };
145 | });
146 | return JSON.stringify({
147 | Items: items,
148 | TotalRecordCount: 0
149 | });
150 | }
151 |
152 | function handleClearDictZone(r, searchTerm) {
153 | let msg;
154 | const value = searchTerm.split(DIRECTIVE_SPLIT_ENUM.valueSplit.key)[1];
155 | if (value) {
156 | if (value.includes("route")) {
157 | ngx.shared[value].clear();
158 | msg = `clear ${success ? "success" : "fail"}: ${value}`;
159 | r.warn(`handleClearDictZone: ${msg}`);
160 | } else {
161 | msg = `not allow: ${value}`;
162 | r.warn(`handleClearDictZone: ${msg}`);
163 | }
164 | } else {
165 | ngx.shared[DICT_ZONE_EMUN.routeL1Dict.key].clear();
166 | ngx.shared[DICT_ZONE_EMUN.routeL2Dict.key].clear();
167 | msg = `clear routeDict success, skip others`;
168 | r.warn(`handleClearDictZone: ${msg}`);
169 | }
170 | return JSON.stringify({
171 | Items: [{
172 | Name: searchTerm,
173 | SeriesName: msg,
174 | Type: ARGS.itemsType,
175 | }],
176 | TotalRecordCount: 0
177 | });
178 | }
179 |
180 | // for js_set
181 | function getNocache(r) {
182 | const value = ngx.shared[ARGS.dictName].get(DIRECTIVE_KEY_ENUM.skipImageCache.key) ?? "";
183 | // r.log(`getNocache: ${value}`);
184 | return value;
185 | }
186 |
187 | export default {
188 | searchHandle,
189 | getNocache,
190 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/emby-system.js:
--------------------------------------------------------------------------------
1 | // @author: chen3861229
2 | // @date: 2024-07-13
3 |
4 | import config from "../constant.js";
5 | import util from "../common/util.js";
6 | import urlUtil from "../common/url-util.js";
7 | import events from "../common/events.js";
8 | import emby from "../emby.js";
9 | // import embyApi from "../api/emby-api.js";
10 |
11 | async function systemInfoHandler(r) {
12 | events.njsOnExit(`systemInfoHandler: ${r.uri}`);
13 |
14 | // /emby/System/Info?api_key=xxx is important,access token(api_key) is invalid,clients redirects itself to the login page
15 | // r.variables.request_uri = r.variables.request_uri.replace(r.args.api_key, config.embyApiKey);
16 | const subR = await r.subrequest(urlUtil.proxyUri(r.uri), {
17 | method: r.method,
18 | });
19 | let body;
20 | if (subR.status === 200) {
21 | body = JSON.parse(subR.responseText);
22 | } else {
23 | r.warn(`systemInfoHandler subrequest failed, status: ${subR.status}`);
24 | return emby.internalRedirect(r);
25 | }
26 | const currentPort = parseInt(r.variables.server_port);
27 | const originPort = parseInt(body.WebSocketPortNumber);
28 | body.WebSocketPortNumber = currentPort;
29 | if (body.HttpServerPortNumber) {
30 | body.HttpServerPortNumber = currentPort;
31 | }
32 | if (body.LocalAddresses) {
33 | body.LocalAddresses.forEach((s, i, arr) => {
34 | arr[i] = s.replace(originPort, currentPort);
35 | });
36 | }
37 | if (body.RemoteAddresses) {
38 | body.RemoteAddresses.forEach((s, i, arr) => {
39 | arr[i] = s.replace(originPort, currentPort);
40 | });
41 | }
42 | // old clients
43 | if (body.LocalAddress) {
44 | body.LocalAddress = body.LocalAddress.replace(originPort, currentPort);
45 | }
46 | if (body.WanAddress) {
47 | body.WanAddress = body.WanAddress.replace(originPort, currentPort);
48 | }
49 | util.copyHeaders(subR.headersOut, r.headersOut);
50 | return r.return(200, JSON.stringify(body));
51 | }
52 |
53 | export default {
54 | systemInfoHandler,
55 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/emby-transcode.js:
--------------------------------------------------------------------------------
1 | // @author: Chen3861229
2 | // @date: 2024-02-07
3 |
4 | import config from "../constant.js";
5 | import util from "../common/util.js";
6 | import events from "../common/events.js";
7 | import emby from "../emby.js";
8 | import embyApi from "../api/emby-api.js";
9 |
10 | import qs from "querystring";
11 |
12 | let keys = {
13 | idKey: "Id",
14 | filePathKey: "Path",
15 | mediaSourcesKey: "MediaSources",
16 | }
17 |
18 | async function transcodeBalance(r) {
19 | if (!checkEnable(r)) {
20 | return emby.internalRedirectExpect(r);
21 | }
22 | events.njsOnExit(`transcodeBalance: ${r.uri}`);
23 | const ua = r.headersIn["User-Agent"];
24 |
25 | // const routeInternalDictKey = `${r.args.MediaSourceId}:${urlUtil.getDeviceId(r)}`;
26 |
27 | // getCurrentItemInfo
28 | const currentItem = await getCurrentItemInfo(r);
29 | if (!currentItem) {
30 | return emby.internalRedirectExpect(r);
31 | }
32 |
33 | // add Expression Context to r
34 | r[util.ARGS.rXMediaKey] = currentItem;
35 | ngx.log(ngx.WARN, `add emby/jellyfin currentItem to r: ${JSON.stringify(r[util.ARGS.rXMediaKey])}`);
36 | // routeRule, diff of PlaybackInfo routeRule, prevent bypass so rejudge
37 | const notLocal = util.checkIsStrmByPath(currentItem.path);
38 | const routeMode = util.getRouteMode(r, currentItem.path, false, notLocal);
39 | const apiType = r.variables.apiType ?? "";
40 | r.warn(`getRouteMode: ${routeMode}, apiType: ${apiType}`);
41 | if (util.ROUTE_ENUM.proxy === routeMode) {
42 | return emby.internalRedirectExpect(r);
43 | } else if ((routeMode === util.ROUTE_ENUM.block)
44 | || (routeMode === util.ROUTE_ENUM.blockDownload && apiType.endsWith("Download"))
45 | || (routeMode === util.ROUTE_ENUM.blockPlay && apiType.endsWith("Play"))
46 | // Infuse use VideoStreamPlay to download, UA diff, ignore apiType
47 | || (routeMode === util.ROUTE_ENUM.blockDownload && ua.includes("Infuse"))
48 | ) {
49 | return emby.blocked(r);
50 | }
51 |
52 | // swich transcode opt, skip modify
53 | // if (r.args.StartTimeTicks === "0") {
54 | // // routeRule
55 | // const notLocal = util.checkIsStrmByPath(currentItem.path);
56 | // const routeMode = util.getRouteMode(r, currentItem.path, false, notLocal);
57 | // if (util.ROUTE_ENUM.proxy === routeMode) {
58 | // return emby.internalRedirectExpect(r);
59 | // // not need route, clients will self select
60 | // // } else if (util.ROUTE_ENUM.redirect === routeMode) {
61 | // // // this maybe not support, because player is already inited to HLS
62 | // // return emby.redirect2Pan(r);
63 | // } else if (util.ROUTE_ENUM.block === routeMode) {
64 | // return emby.blocked(r);
65 | // }
66 | // }
67 |
68 | // check transcode load
69 | let transServer = await getTransServer(r);
70 | if (!transServer) {
71 | return emby.internalRedirectExpect(r);
72 | }
73 |
74 | // media item match
75 | const targetItem = await mediaItemMatch(r, currentItem, transServer, keys);
76 | if (!targetItem) {
77 | const targetItemMatchFallback = config.transcodeConfig.targetItemMatchFallback;
78 | r.warn(`targetItemMatchFallback: ${targetItemMatchFallback}`);
79 | if (util.ROUTE_ENUM.proxy == targetItemMatchFallback) {
80 | return emby.internalRedirectExpect(r);
81 | } else {
82 | // util.dictAdd("routeInternalDict", routeInternalDictKey, util.ROUTE_ENUM.redirect);
83 | return r.return(449, "need retry playback");
84 | // return emby.redirect2Pan(r);
85 | }
86 | }
87 | const targetMediaId = targetItem.source ? targetItem.source[keys.idKey] : targetItem.item[keys.idKey];
88 | r.warn(`media item match success target server item id: ${targetMediaId}`);
89 |
90 | // build target server url
91 | // redirect to target server
92 | emby.redirect(r, buildTransServerUrl(r, transServer, targetMediaId));
93 |
94 | // async add cache
95 | util.dictAdd("transcodeDict", r.args["PlaySessionId"], JSON.stringify({
96 | DeviceId: r.args["DeviceId"],
97 | Server: transServer,
98 | TargetItemId: targetItem.item[keys.idKey],
99 | TargetItemSourceId: targetItem.source ? targetItem.source[keys.idKey] : "",
100 | }));
101 | return;
102 | }
103 |
104 | function checkEnable(r) {
105 | let flag = false;
106 | const transcodeConfig = config.transcodeConfig;
107 | if (transcodeConfig && transcodeConfig.enable
108 | && transcodeConfig.type === "distributed-media-server"
109 | && transcodeConfig.server && transcodeConfig.server.length > 0) {
110 | flag = true;
111 | }
112 | return flag;
113 | }
114 |
115 | async function getTransServer(r) {
116 | const transcodeConfig = config.transcodeConfig;
117 | let serverArr = transcodeConfig.server;
118 | if (!serverArr || (serverArr && serverArr.length === 0)) {
119 | return r.warn(`no transServer, will use current server transcode`);
120 | }
121 | const maxNum = transcodeConfig.maxNum;
122 | let target;
123 | let serverTmp;
124 | let transSessions;
125 | let start = Date.now();
126 | for (let i = 0; i < serverArr.length; i++) {
127 | serverTmp = serverArr[i];
128 | try {
129 | transSessions = await embyApi.fetchSessions(serverTmp.host, serverTmp.apiKey, {IsPlaying: true});
130 | } catch (error) {
131 | r.warn(`fetchSessions: ${error}, skip this server: ${serverTmp.host}`);
132 | continue;
133 | }
134 | r.warn(`fetchSessions res.status: ${transSessions.status}`);
135 | transSessions = await transSessions.json();
136 | r.log(`fetchSessions res: ${JSON.stringify(transSessions)}`);
137 | transSessions = transSessions.filter(s => embyApi.PLAY_METHOD_ENUM.Transcode == s.PlayState.PlayMethod);
138 | serverTmp.transcodeNum = transSessions.length;
139 | if (transSessions.length > maxNum) {
140 | r.warn(`hit maxNum, skip this server: ${serverTmp.host}`);
141 | continue;
142 | }
143 | target = serverTmp;
144 | }
145 | if (!target) {
146 | r.warn(`all server overload, will use least transcode`);
147 | target = serverArr.sort((a, b) => a.transcodeNum - b.transcodeNum)[0];
148 | }
149 | let end = Date.now();
150 | r.warn(`cost ${end - start}ms, find target server: ${target.host}`);
151 | if (target.host == config.embyHost) {
152 | r.warn(`find target server same as currentServer`);
153 | return emby.internalRedirectExpect(r);
154 | }
155 | return target;
156 | }
157 |
158 | async function getCurrentItemInfo(r) {
159 | const isEmby = !!config.embyHost;
160 | if (!isEmby) {
161 | return r.error(`not supported media server type`);
162 | }
163 |
164 | let rvt = {
165 | notLocal: false,
166 | itemName: "",
167 | path: "",
168 | itemId: "",
169 | mediaSourceId: "",
170 | }
171 | let mediaServerRes;
172 | if (isEmby) {
173 | const itemInfo = util.getItemInfo(r);
174 | mediaServerRes = await util.cost(emby.fetchEmbyFilePath,
175 | itemInfo.itemInfoUri,
176 | itemInfo.itemId,
177 | itemInfo.Etag,
178 | itemInfo.mediaSourceId
179 | );
180 | r.warn(`fetchEmbyFilePath mediaServerRes: ${JSON.stringify(mediaServerRes)}`);
181 | if (mediaServerRes.message.startsWith("error") || !mediaServerRes.itemName || !mediaServerRes.path) {
182 | return r.error(mediaServerRes.message);
183 | }
184 | rvt.notLocal = mediaServerRes.notLocal;
185 | rvt.itemName = mediaServerRes.itemName;
186 | rvt.path = mediaServerRes.path;
187 | rvt.itemId = itemInfo.itemId;
188 | rvt.mediaSourceId = itemInfo.mediaSourceId;
189 | } else {}
190 | r.log(`mediaServerRes: ${JSON.stringify(mediaServerRes)}`);
191 | r.warn(`getCurrentItemInfo: ${JSON.stringify(rvt)}`);
192 | return rvt;
193 | }
194 |
195 | async function mediaItemMatch(r, currentItem, transServer, keys) {
196 | const isEmby = transServer.type == "emby" || transServer.type == "jellyfin";
197 | if (!isEmby) {
198 | return r.error(`not supported media server type`);
199 | }
200 |
201 | let targetRes;
202 | let targetItems;
203 | if (isEmby) {
204 | try {
205 | targetRes = await util.cost(embyApi.fetchItems,
206 | transServer.host,
207 | transServer.apiKey,
208 | {
209 | SearchTerm: encodeURI(currentItem.itemName),
210 | Limit: 10,
211 | Recursive: true,
212 | Fields: "ProviderIds,Path,MediaSources",
213 | }
214 | );
215 | } catch (error) {
216 | return r.error(`media item match fetchItems: ${error}`);
217 | }
218 | r.warn(`media item match targetRes.status: ${targetRes.status}`);
219 | targetRes = await targetRes.json();
220 | targetItems = targetRes.Items;
221 | } else {
222 | // try {
223 | // targetRes = await util.cost(embyApi.fetchItems,
224 | // transServer.host,
225 | // transServer.apiKey,
226 | // {
227 | // SearchTerm: encodeURI(currentItem.itemName),
228 | // Limit: 10,
229 | // Recursive: true,
230 | // Fields: "ProviderIds,Path,MediaSources",
231 | // }
232 | // );
233 | // } catch (error) {
234 | // r.error(`media item match fetchItems: ${error}`);
235 | // return emby.internalRedirectExpect(r);
236 | // }
237 | // targetRes = await targetRes.json();
238 | // targetItems = targetRes.Items;
239 | // keys.filePathKey = "";
240 | // keys.mediaSourcesKey = "";
241 | }
242 |
243 | r.warn(`media item match fetchItems: ${JSON.stringify(targetItems)}`);
244 | if (targetItems.length < 1) {
245 | return r.error(`media item match not found`);
246 | }
247 |
248 | const currentFileName = currentItem.path.split("/").pop();
249 | let fileNameTmp;
250 | let targetItem; // mutiple versions parent item
251 | let targetItemSource; // mutiple versions detail item
252 | targetItems.map(item => {
253 | fileNameTmp = item[keys.filePathKey].split("/").pop();
254 | if (fileNameTmp == currentFileName) {
255 | targetItem = item;
256 | return;
257 | }
258 | item[keys.mediaSourcesKey].map(source => {
259 | fileNameTmp = source[keys.filePathKey].split("/").pop();
260 | if (fileNameTmp == currentFileName) {
261 | targetItem = item;
262 | targetItemSource = source;
263 | return;
264 | }
265 | });
266 | });
267 | r.warn(`media item match targetItem: ${JSON.stringify(targetItem)}`);
268 | if (!targetItem || (targetItem && !targetItem.Id)) {
269 | return r.error(`media item match not found`);
270 | }
271 |
272 | return { item: targetItem, itemSource: targetItemSource, keys: keys };
273 | }
274 |
275 | function buildTransServerUrl(r, transServer, targetMediaId) {
276 | const isEmby = transServer.type == "emby" || transServer.type == "jellyfin";
277 | if (!isEmby) {
278 | return r.error(`not supported media server type`);
279 | }
280 |
281 | // let oriArgs = r.variables.args;
282 | r.warn(`original args: ${r.variables.args}`);
283 | let rArgs = r.args;
284 | let baseUrl;
285 | if (isEmby) {
286 | for (let k in rArgs) {
287 | // k == "DeviceId"
288 | if (k == "api_key" || k == "MediaSourceId" || k == "TranscodeReasons") {
289 | const newK = "ori_" + k;
290 | rArgs[newK] = rArgs[k];
291 | delete rArgs[k];
292 | }
293 | }
294 | if (transServer.type == "jellyfin") {
295 | // jellyfin, MediaSourceId, The mediaSourceId field is required
296 | rArgs["MediaSourceId"] = targetMediaId;
297 | // jellyfin, StartTimeTicks, Error processing request
298 | // let oriVal = rArgs["StartTimeTicks"];
299 | // if (oriVal) {
300 | // rArgs["ori_StartTimeTicks"] = oriVal;
301 | delete rArgs["StartTimeTicks"];
302 | // rArgs["runtimeTicks"] = oriVal;
303 | // }
304 | }
305 | rArgs["api_key"] = transServer.apiKey;
306 | baseUrl = `${transServer.host}/Videos/${targetMediaId}/master.m3u8`;
307 | } else {
308 | // params mapping
309 | // rArgs["api_key"] = transServer.apiKey;
310 | // baseUrl = `${transServer.host}/Videos/${targetMediaId}/master.m3u8`;
311 | }
312 |
313 | // important, avoid dead loops
314 | rArgs[util.ARGS.useProxyKey] = "1";
315 | let args = qs.stringify(rArgs);
316 | r.warn(`modify args: ${args}`);
317 |
318 | return `${baseUrl}?${args}`;
319 | }
320 |
321 | async function syncDelete(r) {
322 | if (!checkEnable(r)) {
323 | return emby.internalRedirectExpect(r);
324 | }
325 | events.njsOnExit(`syncDelete: ${r.uri}`);
326 |
327 | const uri = r.uri;
328 | let rArgs = r.args;
329 | // Not Expect, this playSessionId on switch video bitrate will always be old value
330 | const playSessionId = rArgs["PlaySessionId"];
331 | r.warn(`syncDelete transcodeDict key: ${playSessionId}`);
332 | const cachedStr = ngx.shared.transcodeDict.get(playSessionId);
333 | if (!cachedStr) {
334 | r.log(`syncDelete playSession not exist, skip, ${uri}`);
335 | return emby.internalRedirectExpect(r);
336 | }
337 | const cacheObj = JSON.parse(cachedStr);
338 | if (!cacheObj) {
339 | r.warn(`syncDelete cacheObj not exist, skip, ${uri}`);
340 | return emby.internalRedirectExpect(r);
341 | }
342 | const server = cacheObj.Server;
343 | if (!server || (server && !server.host)) {
344 | r.warn(`syncDelete targetServer not exist, skip, ${uri}`);
345 | return emby.internalRedirectExpect(r);
346 | }
347 | let res;
348 | try {
349 | res = await embyApi.fetchVideosActiveEncodingsDelete(server.host, server.apiKey, {
350 | DeviceId: cacheObj.DeviceId,
351 | PlaySessionId: playSessionId
352 | });
353 | } catch (error) {
354 | r.warn(`fetchVideosActiveEncodingsDelete: ${error}, skip, ${uri}`);
355 | return emby.internalRedirectExpect(r);
356 | }
357 | if (res && res.ok) {
358 | r.warn(`syncDelete success: ${server.host}`);
359 | }
360 | // After redirect, a new njs VM is started in the target location, the VM in the original location is stopped
361 | return emby.internalRedirectExpect(r);
362 | }
363 |
364 | async function syncPlayState(r) {
365 | if (!checkEnable(r)) {
366 | return emby.internalRedirectExpect(r);
367 | }
368 | events.njsOnExit(`syncPlayState: ${r.uri}`);
369 |
370 | const uri = r.uri;
371 | if (!r.requestText) {
372 | r.warn(`syncPlayState requestText not exist, skip, ${uri}`);
373 | return emby.internalRedirectExpect(r);
374 | }
375 | const reqBody = JSON.parse(r.requestText);
376 | // Expect, this playSessionId is always current
377 | const playSessionId = reqBody["PlaySessionId"];
378 | r.warn(`syncPlayState transcodeDict key: ${playSessionId}`);
379 | const cachedStr = ngx.shared.transcodeDict.get(playSessionId);
380 | if (!cachedStr) {
381 | r.log(`syncPlayState playSession not exist, skip, ${uri}`);
382 | return emby.internalRedirectExpect(r);
383 | }
384 | const cacheObj = JSON.parse(cachedStr);
385 | if (!cacheObj) {
386 | r.warn(`syncPlayState cacheObj not exist, skip, ${uri}`);
387 | return emby.internalRedirectExpect(r);
388 | }
389 | const server = cacheObj.Server;
390 | if (!server || (server && !server.host)) {
391 | r.warn(`syncPlayState targetServer not exist, skip, ${uri}`);
392 | return emby.internalRedirectExpect(r);
393 | }
394 | reqBody["ItemId"] = cacheObj.TargetItemId;
395 | reqBody["MediaSourceId"] = cacheObj.TargetItemSourceId;
396 | reqBody["PlayMethod"] = embyApi.PLAY_METHOD_ENUM.Transcode;
397 | let rArgs = r.args;
398 | if (server.type == "jellyfin") {
399 | delete rArgs["X-Emby-Token"];
400 | }
401 | rArgs["api_key"] = server.apiKey;
402 | let url = `${server.host}${r.uri}?${qs.stringify(rArgs)}`;
403 | r.warn(`syncPlayState fetchUrl: ${url}`);
404 | r.warn(`syncPlayState fetchBody: ${JSON.stringify(reqBody)}`);
405 | ngx.fetch(url, {
406 | method: r.method,
407 | headers: {
408 | "User-Agent": r.headersIn["User-Agent"],
409 | "Content-Type": "application/json"
410 | },
411 | body: JSON.stringify(reqBody),
412 | }).then(res => {
413 | r.warn(`syncPlayState fetch res.status: ${res.status}`);
414 | if (res.ok || res.status === 204) {
415 | r.warn(`syncPlayState success: ${server.host}`);
416 | }
417 | });
418 | return emby.internalRedirectExpect(r);
419 | }
420 |
421 | export default {
422 | transcodeBalance,
423 | syncDelete,
424 | syncPlayState,
425 | };
426 |
--------------------------------------------------------------------------------
/emby2Alist/nginx/conf.d/modules/ngx-ext.js:
--------------------------------------------------------------------------------
1 | // @author: chen3861229
2 | // @date: 2024-07-13
3 |
4 | import config from "../constant.js";
5 | import util from "../common/util.js";
6 | import urlUtil from "../common/url-util.js";
7 | // import events from "../common/events.js";
8 |
9 | /**
10 | * fetchLastLink, actually this just once request,currently sufficient
11 | * @param {String} oriLink eg: "https://alist/d/file.xxx" or "http(s)://xxx"
12 | * @param {String} authType eg: "sign"
13 | * @param {String} authInfo eg: "sign:token:expireTime"
14 | * @param {String} ua
15 | * @returns redirect after link
16 | */
17 | async function fetchLastLink(oriLink, authType, authInfo, ua) {
18 | // this is for multiple instances alist add sign
19 | if (authType && authType === "sign" && authInfo) {
20 | const arr = authInfo.split(":");
21 | oriLink = util.addAlistSign(oriLink, arr[0], parseInt(arr[1]));
22 | }
23 | // this is for current alist add sign
24 | if (config.alistSignEnable) {
25 | oriLink = util.addAlistSign(oriLink, config.alistToken, config.alistSignExpireTime);
26 | }
27 | const url = encodeURI(oriLink);
28 | const urlParts = urlUtil.parseUrl(url);
29 | const hostValue = `${urlParts.host}:${urlParts.port}`;
30 | ngx.log(ngx.WARN, `fetchLastLink add Host: ${hostValue}`);
31 | try {
32 | // fetch Api ignore nginx locations,ngx.ferch,redirects are not handled
33 | const response = await ngx.fetch(url, {
34 | method: "HEAD",
35 | headers: {
36 | "User-Agent": ua,
37 | Host: hostValue,
38 | },
39 | max_response_body_size: 1024
40 | });
41 | const contentType = response.headers["Content-Type"];
42 | ngx.log(ngx.WARN, `fetchLastLink response.status: ${response.status}, contentType: ${contentType}`);
43 | // response.redirected api error return false
44 | if ((response.status > 300 && response.status < 309) || response.status == 403) {
45 | // if handle really LastLink, modify here to recursive and return link on status 200
46 | return response.headers["Location"];
47 | } else if (response.status === 200) {
48 | // alist 401 but return 200 status code
49 | if (contentType.includes("application/json")) {
50 | throw new Error("alist maybe return 401, check your alist sign or auth settings");
51 | }
52 | throw new Error("not expected result");
53 | } else {
54 | throw new Error(`${response.status} ${response.statusText}`);
55 | }
56 | } catch (error) {
57 | ngx.log(ngx.ERR, `error: fetchLastLink: ${error}`);
58 | }
59 | }
60 |
61 | function lastLinkFailback(url) {
62 | if (!url) {
63 | return url;
64 | }
65 | let rvt = alistLinkFailback(url);
66 | return rvt;
67 | }
68 |
69 | function alistLinkFailback(url) {
70 | let rvt = url;
71 | const alistAddr = config.alistAddr;
72 | const alistPublicAddr = config.alistPublicAddr;
73 | let uri = url.replace(alistAddr, "");
74 | if (alistAddr && url.startsWith(alistAddr) && !uri.startsWith("/d/")) {
75 | rvt = `${alistAddr}/d${uri}`;
76 | ngx.log(ngx.WARN, `hit alistLinkFailback, add /d: ${rvt}`);
77 | return rvt;
78 | }
79 | uri = url.replace(alistPublicAddr, "");
80 | if (alistPublicAddr && url.startsWith(alistPublicAddr) && !uri.startsWith("/d/")) {
81 | rvt = `${alistPublicAddr}/d${uri}`;
82 | ngx.log(ngx.WARN, `hit alistLinkFailback, add /d: ${rvt}`);
83 | return rvt;
84 | }
85 | return rvt;
86 | }
87 |
88 | async function linkCheck(url, ua, onlyRedirect) {
89 | const urlParts = urlUtil.parseUrl(url);
90 | const hostValue = `${urlParts.host}:${urlParts.port}`;
91 | ngx.log(ngx.WARN, `linkCheck add Host: ${hostValue}`);
92 | try {
93 | // fetch Api ignore nginx locations,ngx.ferch,redirects are not handled
94 | const response = await ngx.fetch(url, {
95 | method: "HEAD",
96 | headers: {
97 | "User-Agent": ua,
98 | Host: hostValue,
99 | },
100 | max_response_body_size: 1024
101 | });
102 | const contentType = response.headers["Content-Type"];
103 | ngx.log(ngx.WARN, `linkCheck response.status: ${response.status}, contentType: ${contentType}`);
104 | // response.redirected api error return false
105 | if (response.status > 300 && response.status < 309) {
106 | return true;
107 | } else if (response.status === 200) {
108 | // maybe alist 401 but return 200 status code
109 | if (contentType.includes("application/json")) {
110 | throw new Error("linkCheck alist maybe return 401, check your alist sign or auth settings");
111 | }
112 | if (onlyRedirect) {
113 | throw new Error("not expected result, onlyRedirect true but status 200");
114 | }
115 | return true;
116 | } else {
117 | throw new Error(`${response.status} ${response.statusText}`);
118 | }
119 | } catch (error) {
120 | ngx.log(ngx.ERR, `error: linkCheck: ${error}`);
121 | }
122 | }
123 |
124 | // NJS log level only have: info, warn, error
125 | // nginx log level settings emby2Alist/nginx/nginx.conf error_log /var/log/nginx/error.log notice;
126 |
127 | function info(message) {
128 | ngx.log(ngx.INFO, message);
129 | }
130 |
131 | function warn(message) {
132 | ngx.log(ngx.WARN, message);
133 | }
134 |
135 | function error(message) {
136 | ngx.log(ngx.ERR, message);
137 | }
138 |
139 | function log(level, message) {
140 | ngx.log(level, message);
141 | }
142 |
143 | export default {
144 | fetchLastLink,
145 | lastLinkFailback,
146 | linkCheck,
147 | info,
148 | warn,
149 | error,
150 | log,
151 | };
--------------------------------------------------------------------------------
/emby2Alist/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | load_module modules/ngx_http_js_module.so;
2 | #load_module modules/ngx_stream_js_module.so; # don't need this
3 |
4 | user root;
5 | worker_processes auto;
6 |
7 | error_log /var/log/nginx/error.log notice;
8 | pid /var/run/nginx.pid;
9 | # error_log syslog:server=192.168.31.200,tag=error_log notice;
10 |
11 | events {
12 | worker_connections 1024;
13 | }
14 |
15 | http {
16 | include /etc/nginx/mime.types;
17 | default_type application/octet-stream;
18 |
19 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
20 | '$status $body_bytes_sent "$http_referer" '
21 | '"$http_user_agent" "$http_x_forwarded_for"';
22 |
23 | access_log /var/log/nginx/access.log main;
24 | # access_log syslog:server=192.168.31.200,tag=access_log combined;
25 |
26 | sendfile on;
27 | #tcp_nopush on;
28 |
29 | keepalive_timeout 65;
30 |
31 | #gzip on;
32 |
33 | include /etc/nginx/conf.d/*.conf;
34 | }
35 |
--------------------------------------------------------------------------------
/embyAddExternalUrl/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.5'
2 |
3 | services:
4 |
5 | service.nginx:
6 | image: nginx:latest
7 | container_name: nginx-embyUrl
8 | ports:
9 | - 8097:80
10 | volumes:
11 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf
12 | - ./nginx/conf.d:/etc/nginx/conf.d
13 | restart: always
--------------------------------------------------------------------------------
/embyAddExternalUrl/nginx/conf.d/emby.conf:
--------------------------------------------------------------------------------
1 | # Load the njs script
2 | js_path /etc/nginx/conf.d/;
3 | js_import addExternalUrl from externalUrl.js;
4 |
5 | server{
6 | gzip on;
7 | listen 80;
8 | server_name default;
9 | set $emby http://172.17.0.1:8096; #emby address
10 |
11 | # Proxy sockets traffic for jellyfin-mpv-shim and webClient
12 | location ~ /(socket|embywebsocket) {
13 | # Proxy Emby Websockets traffic
14 | proxy_pass $emby;
15 | proxy_http_version 1.1;
16 | proxy_set_header Upgrade $http_upgrade;
17 | proxy_set_header Connection "upgrade";
18 | proxy_set_header Host $host;
19 | proxy_set_header X-Real-IP $remote_addr;
20 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
21 | proxy_set_header X-Forwarded-Proto $scheme;
22 | proxy_set_header X-Forwarded-Protocol $scheme;
23 | proxy_set_header X-Forwarded-Host $http_host;
24 | }
25 |
26 | ## addExternalUrl SETTINGS ##
27 | location ~* /Users/(.*)/Items/(.*)$ {
28 | proxy_buffering off;
29 | js_body_filter addExternalUrl.addExternalUrl buffer_type=string;
30 | proxy_pass $emby;
31 | proxy_pass_request_body off;
32 | proxy_http_version 1.1;
33 | proxy_set_header Upgrade $http_upgrade;
34 | proxy_set_header Connection "upgrade";
35 | proxy_set_header Host $host;
36 | proxy_set_header X-Real-IP $remote_addr;
37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
38 | proxy_set_header X-Forwarded-Proto $scheme;
39 | proxy_set_header X-Forwarded-Protocol $scheme;
40 | proxy_set_header X-Forwarded-Host $http_host;
41 | proxy_set_header Accept-Encoding "identity";
42 | proxy_set_header X-Original-URI $request_uri;
43 | js_header_filter addExternalUrl.HeaderFilter;
44 | }
45 |
46 | location ~* /redirect2external {
47 | js_content addExternalUrl.redirectUrl;
48 | }
49 | ## addExternalUrl SETTINGS ##
50 |
51 | location / {
52 | # Proxy main Emby traffic
53 | proxy_pass $emby;
54 | proxy_set_header Host $host;
55 | proxy_set_header X-Real-IP $remote_addr;
56 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
57 | proxy_set_header X-Forwarded-Proto $scheme;
58 | proxy_set_header X-Forwarded-Protocol $scheme;
59 | proxy_set_header X-Forwarded-Host $http_host;
60 | }
61 | }
--------------------------------------------------------------------------------
/embyAddExternalUrl/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | load_module modules/ngx_http_js_module.so;
2 |
3 | user nginx;
4 | worker_processes auto;
5 |
6 | error_log /var/log/nginx/error.log notice;
7 | pid /var/run/nginx.pid;
8 |
9 |
10 | events {
11 | worker_connections 1024;
12 | }
13 |
14 | http {
15 | include /etc/nginx/mime.types;
16 | default_type application/octet-stream;
17 |
18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" '
19 | '$status $body_bytes_sent "$http_referer" '
20 | '"$http_user_agent" "$http_x_forwarded_for"';
21 |
22 | access_log /var/log/nginx/access.log main;
23 |
24 | sendfile on;
25 | #tcp_nopush on;
26 |
27 | keepalive_timeout 65;
28 |
29 | #gzip on;
30 |
31 | include /etc/nginx/conf.d/*.conf;
32 | }
33 |
--------------------------------------------------------------------------------
/embyWebAddExternalUrl/README.md:
--------------------------------------------------------------------------------
1 |
2 | ### emby 调用外部播放器用户脚本,支持网页和服务端:
3 |
4 | ### 现用户脚本更新地址
5 | https://greasyfork.org/zh-CN/scripts/514529
6 |
7 |
8 | ### 原作者(bpking1)提示信息
9 |
10 | 1. 添加mpv player, 桌面端需要使用这个项目进行设置 https://github.com/akiirui/mpv-handler
11 | 2. 请使用Potplayer官方最新版,目前的版本号是230208,影片标题中文表现为乱码,需要等potplayer官方更新才行
12 | 3. PotPlayer可以调用外挂字幕,未选中外挂字幕的时候默认会尝试加载中文外挂字幕
13 | 4. 取消直链网盘播放按钮,直链需求可以在emby_server解决,请参考 [这篇文章](https://blog.738888.xyz/posts/emby_jellyfin_to_alist_directlink)
14 | 5. potPlayer调用不生效的情况通常是注册表没加上,请重新安装pot官网最新版
15 | 6. 需要多开potPlayer的话,将脚本第186行左右的 /current 删除即可
16 | 7. 推荐直接将js脚本部署到emby_server,这样就不需要油猴了: 以linux为例,在/opt/emby-server/system/dashboard-ui目录下新建externalPlayer.js文件,将本脚本内容复制到里面,然后在当前目录下的index.html的 /body上面引入脚本即可
17 | 8. 提示信息引用至原地址1: https://greasyfork.org/zh-CN/scripts/459297-embylaunchpotplayer
18 | 9. 原脚本的账号无法登陆了,以后在这个地址更新,原地址2 https://greasyfork.org/en/scripts/406811-embylaunchpotplayer ,github地址: https://github.com/bpking1/embyExternalUrl
19 |
20 | ### 部署方式,任选一种
21 |
22 | ## 一.原生部署到服务端上(推荐)
23 | 1. 优点是不依赖其他插件,例如: 油猴/篡改猴, 所有 Web 端共享加载插件,缺点是用户无法手动禁用插件,且非 Web 端不生效
24 | 2. 修改服务端的`../emby-server/system/dashboard-ui/index.html`最下方,/body 标签上,``这行的下方添加,
25 | ```js
26 | ...
27 |
28 |