├── .github
└── workflows
│ └── docker.yml
├── .gitignore
├── .vscode
└── launch.json
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── 2024-08-31-17-15-53.jpg
├── 2024-09-05-17-20-23.png
├── 2024-11-05-09-57-45.jpg
├── 2025-04-27-20-15-06.png
└── logo.png
├── config-example.yml
├── custom-css
└── README.md
├── custom-js
└── README.md
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal
├── config
│ ├── alist.go
│ ├── cache.go
│ ├── config.go
│ ├── emby.go
│ ├── log.go
│ ├── path.go
│ ├── ssl.go
│ └── video_preview.go
├── constant
│ └── constant.go
├── model
│ └── http.go
├── service
│ ├── alist
│ │ ├── api.go
│ │ ├── api_test.go
│ │ ├── path.go
│ │ ├── subtitle.go
│ │ └── type.go
│ ├── emby
│ │ ├── api.go
│ │ ├── auth.go
│ │ ├── cors.go
│ │ ├── custom_cssjs.go
│ │ ├── download.go
│ │ ├── emby.go
│ │ ├── episode.go
│ │ ├── items.go
│ │ ├── media.go
│ │ ├── media_test.go
│ │ ├── playbackinfo.go
│ │ ├── playing.go
│ │ ├── redirect.go
│ │ ├── subtitles.go
│ │ └── type.go
│ ├── m3u8
│ │ ├── info.go
│ │ ├── info_test.go
│ │ ├── m3u8.go
│ │ ├── m3u8_test.go
│ │ ├── proxy.go
│ │ └── type.go
│ └── path
│ │ ├── path.go
│ │ └── path_test.go
├── setup
│ └── setup.go
├── util
│ ├── colors
│ │ └── colors.go
│ ├── encrypts
│ │ └── encrypts.go
│ ├── https
│ │ ├── gin.go
│ │ ├── https.go
│ │ └── https_test.go
│ ├── jsons
│ │ ├── deep_get.go
│ │ ├── gin.go
│ │ ├── item.go
│ │ ├── jsons.go
│ │ ├── jsons_test.go
│ │ └── serialize.go
│ ├── maps
│ │ └── maps.go
│ ├── randoms
│ │ └── randoms.go
│ ├── slices
│ │ └── slices.go
│ ├── strs
│ │ └── strs.go
│ ├── structs
│ │ └── structs.go
│ └── urls
│ │ ├── urls.go
│ │ └── urls_test.go
└── web
│ ├── cache
│ ├── cache.go
│ ├── holder.go
│ ├── public.go
│ ├── space.go
│ └── type.go
│ ├── handler.go
│ ├── log.go
│ ├── referer.go
│ ├── route.go
│ ├── web.go
│ └── webport
│ └── webport.go
├── main.go
└── ssl
└── README.md
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Images
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*.*.*' # 仅在推送符合这个模式的标签时触发
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout repository
13 | uses: actions/checkout@v3
14 |
15 | - name: Set up Docker Buildx
16 | uses: docker/setup-buildx-action@v3
17 |
18 | - name: Log in to Docker Hub
19 | uses: docker/login-action@v2
20 | with:
21 | username: ambitiousjun
22 | password: ${{ secrets.DOCKER_PASSWORD }}
23 |
24 | - name: Set up QEMU
25 | uses: docker/setup-qemu-action@v3
26 |
27 | - name: Build and push Docker images
28 | uses: docker/build-push-action@v6
29 | with:
30 | context: .
31 | platforms: linux/386,linux/arm/v7,linux/amd64,linux/arm64
32 | push: true
33 | tags: |
34 | ambitiousjun/go-emby2alist:${{ github.ref_name }}
35 | ambitiousjun/go-emby2alist:latest
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | config.yml
2 | *debug*
3 | ssl/*crt
4 | ssl/*key
5 | .DS_Store
6 | custom-js/*.js
7 | custom-css/*.css
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Launch Package",
9 | "type": "go",
10 | "request": "launch",
11 | "mode": "auto",
12 | "program": "${workspaceFolder}/main.go",
13 | "console": "integratedTerminal"
14 | }
15 | ]
16 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 第一阶段:构建阶段
2 | FROM golang:1.24 AS builder
3 |
4 | # 设置工作目录
5 | WORKDIR /app
6 |
7 | # 设置代理
8 | RUN go env -w GOPROXY=https://goproxy.cn
9 |
10 | # 复制 go.mod 和 go.sum 文件
11 | COPY go.mod go.sum ./
12 |
13 | # 下载依赖
14 | RUN go mod download
15 |
16 | # 复制源码
17 | COPY . .
18 |
19 | # 编译源码成静态链接的二进制文件
20 | RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o main .
21 |
22 | # 第二阶段:运行阶段
23 | FROM alpine:latest
24 |
25 | # 设置时区
26 | RUN apk add --no-cache tzdata
27 | ENV TZ=Asia/Shanghai
28 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
29 |
30 | # 设置工作目录
31 | WORKDIR /app
32 |
33 | # 从构建阶段复制编译后的二进制文件
34 | COPY --from=builder /app/main .
35 |
36 | # 暴露端口
37 | EXPOSE 8095
38 | EXPOSE 8094
39 |
40 | # 运行应用程序
41 | CMD ["./main"]
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | go-emby2alist
6 |
7 |
8 |

9 |

10 |

11 |

12 |

13 |

14 |
15 |
16 |
17 | Go 语言编写的 Emby + Alist 网盘直链反向代理服务,深度适配阿里云盘转码播放。
18 |
19 |
20 | ## 小白必看
21 |
22 | **网盘直链反向代理**:
23 |
24 | 正常情况下,Emby 通过磁盘挂载的形式间接读取网盘资源,走的是服务器代理模式,看一个视频时数据链路是:
25 |
26 | > 客户端 => Emby 源服务器 => 磁盘挂载服务 => Alist => 网盘
27 | >
28 | > 客户端 <= Emby 源服务器 <= 磁盘挂载服务(将视频数据加载到本地,再给 Emby 读取) <= Alist <= 网盘
29 |
30 | 这种情况有以下局限:
31 |
32 | 1. 视频经过服务器中转,你看视频的最大加载速度就是服务器的上传带宽
33 | 2. 如果服务器性能不行,能流畅播放 1080p 就谢天谢地了,更别说 4K
34 | 3. ...
35 |
36 | 使用网盘直链反向代理后,数据链路:
37 |
38 | > 客户端 => Emby 反代服务器 => Emby 源服务器 (请求 Emby Api 接口)
39 | >
40 | > 客户端 <= Emby 反代服务器 <= Emby 源服务器 (返回数据)
41 |
42 | 对于普通的 Api 接口,反代服务器将请求反代到源服务器,再将合适的结果进行缓存,返回给客户端
43 |
44 | 对于客户端来说,这一步和直连源服务器看不出差别
45 |
46 | > 客户端 => Emby 反代服务器 => Alist => 网盘 (请求视频直链)
47 | >
48 | > 客户端 <= Emby 反代服务器 <= Alist <= 网盘 (返回视频直链,并给出重定向响应)
49 | >
50 | > 客户端 => 网盘(客户端拿着网盘的直链直接观看,此时已经没有服务器的事情了,故不会再消耗服务器流量)
51 |
52 | 这种方式的好处:
53 |
54 | 1. 观看时加载速度拉满(前提是有网盘会员)
55 | 2. 在客户端处解码,能不能看 4K 取决于你电视盒子的性能
56 |
57 | ## 使用前须知
58 |
59 | 1. 本项目初衷: 易用轻巧、小白友好、深度适配阿里云盘 (如果你使用本项目观看其他网盘时出现问题,也欢迎到 issue 区反馈,我会尽量适配它)
60 |
61 | 2. 如果你有更复杂的需求, 推荐使用功能更完善的反向代理服务:[bpking1/embyExternalUrl](https://github.com/bpking1/embyExternalUrl)
62 |
63 | ## 功能
64 |
65 | - Alist 网盘原画直链播放
66 |
67 | - Strm 直链播放
68 |
69 | - Alist 网盘转码直链播放
70 |
71 | > 该功能是通过请求 Alist 的 `/api/fs/other` 接口来实现转码直链获取
72 | >
73 | > 该接口并不是对所有网盘驱动都支持,目前已知 Aliyun_Open 可以正常体验
74 | >
75 | >
76 | >
77 | > **是否消耗三方流量包流量**:🙅
78 | >
79 | > **非会员是否限速**:自行测试
80 | >
81 | >
82 | >
83 | > 示例图 ↓:
84 | >
85 | >
86 | >
87 | >
88 | >
89 | > 现阶段,转码资源直链已达到可正常使用的标准
90 | >
91 | > Emby Web, Emby for AndroidTV 以及其他的客户端都可以正常播放,并且不会因为直链过期而中断
92 | >
93 | >
94 | >
95 | > 局限:
96 | >
97 | > 如果是有多个内置音频的,转码直链只能播放其中的默认音频
98 | >
99 | > 视频本身的内封字幕会丢失,不过若存在转码字幕,也会适配到转码版本的 PlaybackInfo 信息中,示例图 ↓:
100 | >
101 | >
102 | >
103 |
104 | - websocket 代理
105 |
106 | - 客户端防转码(转容器)
107 |
108 | - 缓存中间件,实际使用体验不会比直连源服务器差
109 |
110 | - 字幕缓存(字幕缓存时间固定 30 天)
111 |
112 | > 目前还无法阻止 Emby 去本地挂载文件上读取字幕
113 | >
114 | > 带字幕的视频首次播放时,Emby 会调用 FFmpeg 将字幕从本地文件中提取出来,再进行缓存
115 | >
116 | > 也就是说:
117 | >
118 | > - 首次提取时,速度会很慢,有可能得等个大半天才能看到字幕(使用第三方播放器【如 `MX player`, `Fileball`】可以解决)
119 | > - 带字幕的视频首次播放时,还是会消耗服务器的流量
120 |
121 | - 直链缓存(为了兼容阿里云盘,直链缓存时间目前固定为 10 分钟,其他云盘暂无测试)
122 |
123 | - 大接口缓存(Alist 转码资源是通过代理并修改 PlaybackInfo 接口实现,请求比较耗时,每次大约 2~3 秒左右,目前已经利用 Go 语言的并发优势,尽力地将接口处理逻辑异步化,快的话 1 秒即可请求完成,该接口的缓存时间目前固定为 12 小时,后续如果出现异常再作调整)
124 |
125 | - 自定义注入 js/css(web)
126 |
127 |
128 |
129 | ## 已测试并支持的客户端
130 |
131 | | 名称 | 最后测试版本 | 原画 | 其他说明(原画) | 阿里转码 | 其他说明(阿里转码) |
132 | | ------------------------ | ------------ | ---- | ------------------------------------------------------------ | -------- | ------------------------------------------------------------ |
133 | | `Emby Web` | `4.8.8.0` | ✅ | —— | ✅ | 1. 转码字幕有概率挂载不上
2. 可以挂载原画字幕 |
134 | | `Emby for iOS` | —— | ❓ | ~~没高级订阅测不了~~ | ❓ | ~~没高级订阅测不了~~ |
135 | | `Emby for macOS` | —— | ❓ | ~~没高级订阅测不了~~ | ❓ | ~~没高级订阅测不了~~ |
136 | | `Emby for Android` | `3.4.23` | ✅ | —— | ✅ | —— |
137 | | `Emby for AndroidTV` | `2.0.95g` | ✅ | 遥控器调进度可能会触发直链服务器的频繁请求限制,导致视频短暂不可播情况 | ✅ | 无法挂载字幕 |
138 | | `Fileball` | —— | ✅ | —— | ✅ | —— |
139 | | `Infuse` | —— | ✅ | 在设置中将缓存方式设置为`不缓存`可有效防止触发频繁请求 | ❌ | —— |
140 | | `VidHub` | —— | ✅ | 仅测试至 `1.0.7` 版本 | ✅ | 仅测试至 `1.0.7` 版本 |
141 | | `Stream Music` | `1.3.4` | ✅ | —— | —— | —— |
142 | | `Emby for Kodi Next Gen` | `11.1.13` | ✅ | —— | ✅ | 1. 需要开启插件设置:**播放/视频转码/prores**
2. 播放时若未显示转码版本选择,需重置本地数据库重新全量扫描资料库
3. 某个版本播放失败需要切换版本时,必须重启 kodi 才能重新选择版本
4. 无法挂载字幕 |
143 |
144 |
145 |
146 | ## 使用说明
147 |
148 | 1. 已有自己的 Emby、Alist 服务器
149 |
150 | 2. Emby 的媒体库路径(本地磁盘路径)是和 Alist 挂载路径能够对应上的
151 |
152 | > 这一步前缀对应不上没关系,可以在配置中配置前缀映射 `path.emby2alist` 解决
153 |
154 | 3. 需要有一个中间服务,将网盘的文件数据挂载到系统本地磁盘上,才能被 Emby 读取到
155 |
156 | > 目前我知道的比较好用的服务有两个:[rclone](https://rclone.org/) 和 [CloudDrive2](https://www.clouddrive2.com/)(简称 cd2)
157 | >
158 | >
159 | >
160 | > 如果你的网盘跟我一样是阿里云盘,推荐使用 cd2 直接连接阿里云盘,然后根路径和 Alist 保持即可
161 | >
162 | > 在 cd2 中,找到一个 `最大缓存大小` 的配置,推荐将其设为一个极小值(我是 1MB),这样在刮削的时候就不会消耗太多三方权益包的流量
163 | >
164 | >
165 | >
166 | > ⚠️ 不推荐中间服务直接去连接 Alist 的 WebDav 服务,如果 Alist Token 刷新失败或者是请求频繁被暂时屏蔽,会导致系统本地的挂载路径丢失,Emby 就会认为资源被删除了,然后元数据就丢了,再重新挂载回来后就需要重新刮削了。
167 |
168 | 4. 服务器有安装 Docker
169 |
170 | > 网上有很多 Docker 安装教程,这里我不细说
171 | >
172 | > 不过在国内,很多镜像服务器都不能正常使用了
173 | >
174 | > ~~这里推荐一个好用的[镜像加速源](https://baidu.com)~~
175 | >
176 | > 由于加速源作者服务器压力太大,我就不再这再推荐了
177 |
178 | 5. Git
179 |
180 | > 非必须,如果你想体验测试版,就需要通过 Git 拉取远程源码构建
181 | >
182 | > 正式版可以直接使用现成的 Docker 镜像
183 |
184 | ## 使用 DockerCompose 部署安装
185 |
186 | ### 通过源码构建
187 |
188 | 1. 获取代码
189 |
190 | ```shell
191 | git clone --branch v1.7.4 --depth 1 https://ghproxy.cc/https://github.com/AmbitiousJun/go-emby2alist
192 | cd go-emby2alist
193 | ```
194 |
195 | 2. 拷贝配置
196 |
197 | ```shell
198 | cp config-example.yml config.yml
199 | ```
200 |
201 | 3. 根据自己的服务器配置好 `config.yml` 文件
202 |
203 | 关于路径映射的配置示例图:
204 |
205 | 
206 |
207 | 4. 编译并运行容器
208 |
209 | ```shell
210 | docker-compose up -d --build
211 | ```
212 |
213 | 5. 浏览器访问服务器 ip + 端口 `8095`,开始使用
214 |
215 | > 如需要自定义端口,在第四步编译之前,修改 `docker-compose.yml` 文件中的 `8095:8095` 为 `[自定义端口]:8095` 即可
216 |
217 | 6. 日志查看
218 |
219 | ```shell
220 | docker logs -f go-emby2alist -n 1000
221 | ```
222 |
223 | 7. 修改配置的时候需要重新启动容器
224 |
225 | ```shell
226 | docker-compose down
227 | # 修改 config.yml ...
228 | docker-compose up -d
229 | ```
230 |
231 | 8. 版本更新
232 |
233 | ```shell
234 | # 获取到最新代码后, 可以检查一下 config-example.yml 是否有新增配置
235 | # 及时同步自己的 config.yml 才能用上新功能
236 |
237 | # 更新到正式版
238 | docker-compose down
239 | git fetch --tag
240 | git checkout <版本号>
241 | git pull
242 | docker-compose up -d --build
243 |
244 | # 更新到测试版 (仅尝鲜, 不稳定)
245 | docker-compose down
246 | git checkout main
247 | git pull origin main
248 | docker-compose up -d --build
249 | ```
250 |
251 | 9. 清除过时的 Docker 镜像
252 |
253 | ```shell
254 | docker image prune -f
255 | ```
256 |
257 | ### 使用现有镜像
258 |
259 | 1. 准备配置
260 |
261 | 参考[示例配置](https://github.com/AmbitiousJun/go-emby2alist/blob/v1.7.4/config-example.yml),配置好自己的服务器信息,保存并命名为 `config.yml`
262 |
263 | 2. 创建 docker-compose 文件
264 |
265 | 在配置相同目录下,创建 `docker-compose.yml` 粘贴以下代码:
266 |
267 | ```yaml
268 | version: "3.1"
269 | services:
270 | go-emby2alist:
271 | image: ambitiousjun/go-emby2alist:v1.7.4
272 | environment:
273 | - TZ=Asia/Shanghai
274 | - GIN_MODE=release
275 | container_name: go-emby2alist
276 | restart: always
277 | volumes:
278 | - ./config.yml:/app/config.yml
279 | - ./ssl:/app/ssl
280 | - ./custom-js:/app/custom-js
281 | - ./custom-css:/app/custom-css
282 | ports:
283 | - 8095:8095 # http
284 | - 8094:8094 # https
285 | ```
286 |
287 | 3. 运行容器
288 |
289 | ```shell
290 | docker-compose up -d --build
291 | ```
292 |
293 | ## 关于 ssl
294 |
295 | **使用方式:**
296 |
297 | 1. 将证书和私钥放到程序根目录下的 `ssl` 目录中
298 | 2. 再将两个文件的文件名分别配置到 `config.yml` 中
299 |
300 | **特别说明:**
301 |
302 | 在容器内部,已经将 https 端口写死为 `8094`,将 http 端口写死为 `8095`
303 |
304 | 如果需要自定义端口,仍然是在 `docker-compose.yml` 中将宿主机的端口映射到这两个端口上即可
305 |
306 | **已知问题:**
307 |
308 | 可能有部分客户端会出现首次用 https 成功连上了,下次再打开客户端时,就自动变回到 http 连接,目前不太清楚具体的原因
309 |
310 | ## 自定义注入 web js 脚本
311 |
312 | **使用方式:** 将自定义脚本文件以 `.js` 后缀命名放到程序根目录下的 `custom-js` 目录后重启服务自动生效
313 |
314 | **远程脚本:** 将远程脚本的 http 访问地址写入以 `.js` 后缀命名的文件后(**如编辑器报错请无视**)放到程序根目录下的 `custom-js` 目录后重启服务自动生效
315 |
316 | **注意事项:** 确保多个不同的文件必须都是相同的编码格式(推荐 UTF-8)
317 |
318 | **示例脚本:**
319 |
320 | | 描述 | 获取脚本 | 自用优化版本 |
321 | | --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
322 | | 生成外部播放器按钮 | [ExternalPlayers.js](https://emby-external-url.7o7o.cc/embyWebAddExternalUrl/embyLaunchPotplayer.js) | --- |
323 | | 首页轮播图 | [emby-swiper.js](https://raw.githubusercontent.com/newday-life/emby-web-mod/refs/heads/main/emby-swiper/emby-swiper.js) | [媒体库合并 + 每日清空缓存](https://github.com/AmbitiousJun/emby-css-js/raw/refs/heads/main/custom-js/emby-swiper.js) |
324 | | 隐藏无图片演员 | [actorPlus.js](https://raw.githubusercontent.com/newday-life/emby-web-mod/refs/heads/main/actorPlus/actorPlus.js) | --- |
325 | | 键盘 w/s 控制播放音量 | [audio-keyboard.js](https://github.com/AmbitiousJun/emby-css-js/blob/main/custom-js/audio-keyboard.js) | --- |
326 |
327 | ## 自定义注入 web css 样式表
328 |
329 | **使用方式:** 将自定义样式表文件以 `.css` 后缀命名放到程序根目录下的 `custom-css` 目录后重启服务自动生效
330 |
331 | **远程样式表:** 将远程样式表的 http 访问地址写入以 `.css` 后缀命名的文件后(**如编辑器报错请无视**)放到程序根目录下的 `custom-css` 目录后重启服务自动生效
332 |
333 | **注意事项:** 确保多个不同的文件必须都是相同的编码格式(推荐 UTF-8)
334 |
335 | **示例样式:**
336 |
337 | | 描述 | 获取样式 | 自用优化版本 |
338 | | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------ |
339 | | 调整音量调整控件位置 | [音量条+控件修改.css](https://t.me/Emby_smzase1/74) | --- |
340 | | 节目界面样式美化 | [节目界面.txt](https://t.me/embycustomcssjs/10?comment=159) | [下拉框元素对齐](https://github.com/AmbitiousJun/emby-css-js/raw/refs/heads/main/custom-css/show-display.css) |
341 |
342 | ## 开发计划
343 |
344 | 1. - [x] 进一步优化 m3u8 转码直链的兼容性
345 |
346 | > ✅ 已通过本地代理并重定向 ts 解决 m3u8 直链过期问题
347 | >
348 |
349 | 2. - [ ] ~~电视直播直链反代(实现真直链反代,不需要经过 emby 内部对源地址可用性的校验)~~
350 |
351 | > ❌ 试了一下之前的想法,发现想多了,遂放弃
352 |
353 | 3. - [x] 适配 ssl
354 |
355 | 4. - [ ] ~~Emby 官方客户端的字幕问题~~
356 |
357 | > ❌ 现阶段无法阻止播放带字幕资源时,Emby 调用 FFmpeg 去提取字幕导致的服务器流量消耗问题
358 | >
359 | > 尝试调研过手动利用 FFmpeg 将 Alist 直链字幕直接提取出来,但发现无论怎样 FFmpeg 都必须将整个视频下载到本地才能输出完整的字幕文件 😌
360 | >
361 | > 遂放弃
362 |
363 | 5. ... (如果有什么更好的想法,欢迎 issue 区留言)
364 |
365 | ## 请我喝杯 9.9💰 的 Luckin Coffee☕️
366 |
367 |
368 |
369 | ## Star History
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
--------------------------------------------------------------------------------
/assets/2024-08-31-17-15-53.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AmbitiousJun/go-emby2alist/41eb4ab4f6f50847384219bc255e976c9e311481/assets/2024-08-31-17-15-53.jpg
--------------------------------------------------------------------------------
/assets/2024-09-05-17-20-23.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AmbitiousJun/go-emby2alist/41eb4ab4f6f50847384219bc255e976c9e311481/assets/2024-09-05-17-20-23.png
--------------------------------------------------------------------------------
/assets/2024-11-05-09-57-45.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AmbitiousJun/go-emby2alist/41eb4ab4f6f50847384219bc255e976c9e311481/assets/2024-11-05-09-57-45.jpg
--------------------------------------------------------------------------------
/assets/2025-04-27-20-15-06.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AmbitiousJun/go-emby2alist/41eb4ab4f6f50847384219bc255e976c9e311481/assets/2025-04-27-20-15-06.png
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AmbitiousJun/go-emby2alist/41eb4ab4f6f50847384219bc255e976c9e311481/assets/logo.png
--------------------------------------------------------------------------------
/config-example.yml:
--------------------------------------------------------------------------------
1 | emby:
2 | host: http://192.168.0.109:8096 # emby 访问地址
3 | mount-path: /data # rclone/cd2 挂载的本地磁盘路径, 如果 emby 是容器部署, 这里要配的就是容器内部的挂载路径
4 | episodes-unplay-prior: true # 是否修改剧集排序, 让未播的剧集靠前排列; 启用该配置时, 会忽略原接口的分页机制
5 | resort-random-items: true # 是否重排序随机列表, 对 emby 的排序结果进行二次重排序, 使得列表足够随机
6 | # 代理异常处理策略
7 | # origin: 代理回源服务器处理
8 | # reject: 拒绝处理
9 | proxy-error-strategy: origin
10 | # 图片质量, 默认请求原图
11 | # 配置范围: [1, 100]
12 | # 建议范围: [70, 90]
13 | # 具体数值根据自己的实际情况作调整
14 | images-quality: 100
15 | strm: # 远程视频 strm 配置
16 | # 路径映射, 将 strm 文件内的路径片段替换成指定路径片段
17 | # 可配置多个映射, 每个映射需要有 2 个片段, 使用 [=>] 符号进行分割, 程序自上而下映射第一个匹配的结果
18 | # 这个配置的映射是比较灵活的, 不一定必须按照前缀映射, 可以直接将地址中间的片段给替换掉
19 | #
20 | # 举个栗子
21 | # strm 文件内容: https://test-res.com:8094/1.mp4, 替换结果: http://localhost:8095/1.mp4
22 | # strm 文件内容: https://test-res.com:12138/test-id-12138.mp4, 替换结果: https://test-res.com:10086/test-id-12138.mp4
23 | path-map:
24 | - https://test-res.com:8094 => http://localhost:8095
25 | - 12138 => 10086
26 | # emby 下载接口处理策略
27 | # 403: 禁用下载接口, 返回 403 响应
28 | # origin: 代理到源服务器
29 | # direct: 获取并重定向到直链地址
30 | download-strategy: 403
31 | # emby 本地媒体根目录
32 | # 检测到该路径为前缀的媒体时, 代理回源处理
33 | local-media-root: /data/local
34 |
35 | # 该配置仅针对通过磁盘挂载方式接入的网盘, 如果你使用的是 strm, 可忽略该配置
36 | alist:
37 | host: http://192.168.0.109:5244 # alist 访问地址
38 | token: alist-xxxxx # alist api key 可以在 alist 管理后台查看
39 |
40 | # 该配置项目前只对阿里云盘生效, 如果你使用的是其他网盘, 请直接将 enable 设置为 false
41 | video-preview:
42 | enable: true # 是否开启 alist 转码资源信息获取
43 | containers: # 对哪些视频容器获取转码资源信息
44 | - mp4
45 | - mkv
46 | ignore-template-ids: # 忽略哪些转码清晰度
47 | - LD
48 | - SD
49 |
50 | path:
51 | # emby 挂载路径和 alist 真实路径之间的前缀映射
52 | # 冒号左边表示本地挂载路径, 冒号右边表示 alist 的真实路径
53 | # 这个配置请再三确认配置正确, 可以减少很多不必要的网络请求
54 | emby2alist:
55 | - /movie:/电影
56 | - /music:/音乐
57 | - /show:/综艺
58 | - /series:/电视剧
59 | - /sport:/运动
60 | - /animation:/动漫
61 |
62 | cache:
63 | # 是否启用缓存中间件
64 | # 推荐启用, 既可以缓存 Emby 的大接口以及静态资源, 又可以缓存网盘直链, 避免频繁请求
65 | enable: true
66 | # 缓存过期时间
67 | #
68 | # 可配置单位: d(天), h(小时), m(分钟), s(秒)
69 | #
70 | # 该配置不会影响特殊接口的缓存时间
71 | # 比如直链获取接口的缓存时间固定为 10m, 字幕获取接口的缓存时间固定为 30d
72 | expired: 1d
73 |
74 | ssl:
75 | enable: false # 是否启用 https
76 | # 是否使用单一端口
77 | #
78 | # 启用: 程序会在 8094 端口上监听 https 连接, 不监听 http
79 | # 不启用: 程序会在 8094 端口上监听 https 连接, 在 8095 端口上监听 http 连接
80 | single-port: false
81 | key: testssl.cn.key # 私钥文件名
82 | crt: testssl.cn.crt # 证书文件名
83 |
84 | log:
85 | # 是否禁用控制台彩色日志
86 | #
87 | # 程序默认是输出彩色日志的,
88 | # 如果你的终端不支持彩色输出, 并且多出来一些乱码字符
89 | # 可以将该项设置为 true
90 | disable-color: false
--------------------------------------------------------------------------------
/custom-css/README.md:
--------------------------------------------------------------------------------
1 | ## 自定义 web 样式表统一存放处
2 |
3 | 将要运行的 `.css` 后缀样式表文件保存在此目录下, 重启服务即可自动生效
--------------------------------------------------------------------------------
/custom-js/README.md:
--------------------------------------------------------------------------------
1 | ## 自定义 web 脚本统一存放处
2 |
3 | 将要运行的 `.js` 后缀脚本文件保存在此目录下, 重启服务即可自动生效
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.1'
2 | services:
3 | go-emby2alist:
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | environment:
8 | - TZ=Asia/Shanghai
9 | - GIN_MODE=release
10 | container_name: go-emby2alist
11 | restart: always
12 | volumes:
13 | - ./config.yml:/app/config.yml
14 | - ./ssl:/app/ssl
15 | - ./custom-js:/app/custom-js
16 | - ./custom-css:/app/custom-css
17 | ports:
18 | - 8095:8095 # http
19 | - 8094:8094 # https
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/AmbitiousJun/go-emby2alist
2 |
3 | go 1.24.2
4 |
5 | require (
6 | github.com/gin-gonic/gin v1.10.0
7 | golang.org/x/sync v0.13.0
8 | gopkg.in/yaml.v3 v3.0.1
9 | )
10 |
11 | require (
12 | github.com/bytedance/sonic v1.13.2 // indirect
13 | github.com/bytedance/sonic/loader v0.2.4 // indirect
14 | github.com/cloudwego/base64x v0.1.5 // indirect
15 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect
16 | github.com/gin-contrib/sse v1.1.0 // indirect
17 | github.com/go-playground/locales v0.14.1 // indirect
18 | github.com/go-playground/universal-translator v0.18.1 // indirect
19 | github.com/go-playground/validator/v10 v10.26.0 // indirect
20 | github.com/goccy/go-json v0.10.5 // indirect
21 | github.com/json-iterator/go v1.1.12 // indirect
22 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect
23 | github.com/leodido/go-urn v1.4.0 // indirect
24 | github.com/mattn/go-isatty v0.0.20 // indirect
25 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
26 | github.com/modern-go/reflect2 v1.0.2 // indirect
27 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect
28 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
29 | github.com/ugorji/go/codec v1.2.12 // indirect
30 | golang.org/x/arch v0.16.0 // indirect
31 | golang.org/x/crypto v0.37.0 // indirect
32 | golang.org/x/net v0.39.0 // indirect
33 | golang.org/x/sys v0.32.0 // indirect
34 | golang.org/x/text v0.24.0 // indirect
35 | google.golang.org/protobuf v1.36.6 // indirect
36 | )
37 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
2 | github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
3 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
4 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
5 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
6 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
7 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
8 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
13 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
14 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
15 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
16 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
17 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
18 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
19 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
20 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
21 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
22 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
23 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
24 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
25 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
26 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
27 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
28 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
29 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
30 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
31 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
32 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
33 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
34 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
35 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
36 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
37 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
38 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
39 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
40 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
41 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
44 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
45 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
46 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
47 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
51 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
52 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
53 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
54 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
55 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
56 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
57 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
58 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
59 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
60 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
61 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
62 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
63 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
64 | golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
65 | golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
66 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
67 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
68 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
69 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
70 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
71 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
72 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
73 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
74 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
75 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
76 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
77 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
78 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
79 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
80 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
81 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
82 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
83 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
84 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
85 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
86 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
87 |
--------------------------------------------------------------------------------
/internal/config/alist.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs"
7 | )
8 |
9 | type Alist struct {
10 | // Token 访问 alist 接口的密钥, 在 alist 管理后台获取
11 | Token string `yaml:"token"`
12 | // Host alist 访问地址(如果 alist 使用本地代理模式, 则这个地址必须配置公网可访问地址)
13 | Host string `yaml:"host"`
14 | }
15 |
16 | func (a *Alist) Init() error {
17 | if strs.AnyEmpty(a.Token) {
18 | return errors.New("alist.token 配置不能为空")
19 | }
20 | if strs.AnyEmpty(a.Host) {
21 | return errors.New("alist.host 配置不能为空")
22 | }
23 | return nil
24 | }
25 |
--------------------------------------------------------------------------------
/internal/config/cache.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strconv"
7 | "time"
8 | )
9 |
10 | // durationMap 字符串配置映射成 time.Duration
11 | var durationMap = map[string]time.Duration{
12 | "d": time.Hour * 24,
13 | "h": time.Hour,
14 | "m": time.Minute,
15 | "s": time.Second,
16 | }
17 |
18 | type Cache struct {
19 | Enable bool `yaml:"enable"` // 是否启用缓存
20 | Expired string `yaml:"expired"` // 缓存过期时间
21 | expired time.Duration // 配置初始化转换之后的标准时间对象
22 | }
23 |
24 | func (c *Cache) ExpiredDuration() time.Duration {
25 | return c.expired
26 | }
27 |
28 | func (c *Cache) Init() error {
29 | if len(c.Expired) == 0 {
30 | // 缓存默认过期时间一天
31 | c.expired = time.Hour * 24
32 | } else {
33 | timeFlag := c.Expired[len(c.Expired)-1:]
34 | duration, ok := durationMap[timeFlag]
35 | if !ok {
36 | return fmt.Errorf("cache.expired 配置错误: %s, 支持的时间单位: s, m, h, d", timeFlag)
37 | }
38 | base, err := strconv.Atoi(c.Expired[:len(c.Expired)-1])
39 | if err != nil {
40 | return fmt.Errorf("cache.expired 配置错误: %v", err)
41 | }
42 | if base < 1 {
43 | return fmt.Errorf("cache.exipred 配置错误: %d, 值需大于 0", base)
44 | }
45 | c.expired = time.Duration(base) * duration
46 | }
47 |
48 | if c.Enable {
49 | log.Println("缓存中间件已启用, 过期时间: ", c.Expired)
50 | }
51 |
52 | return nil
53 | }
54 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "reflect"
8 |
9 | "gopkg.in/yaml.v3"
10 | )
11 |
12 | type Config struct {
13 | // Emby emby 相关配置
14 | Emby *Emby `yaml:"emby"`
15 | // Alist alist 相关配置
16 | Alist *Alist `yaml:"alist"`
17 | // VideoPreview 网盘转码链接代理配置
18 | VideoPreview *VideoPreview `yaml:"video-preview"`
19 | // Path 路径相关配置
20 | Path *Path `yaml:"path"`
21 | // Cache 缓存相关配置
22 | Cache *Cache `yaml:"cache"`
23 | // Ssl ssl 相关配置
24 | Ssl *Ssl `yaml:"ssl"`
25 | // Log 日志相关配置
26 | Log *Log `yaml:"log"`
27 | }
28 |
29 | // C 全局唯一配置对象
30 | var C *Config
31 |
32 | // BasePath 配置文件所在的基础路径
33 | var BasePath string
34 |
35 | type Initializer interface {
36 | // Init 配置初始化
37 | Init() error
38 | }
39 |
40 | // ReadFromFile 从指定文件中读取配置
41 | func ReadFromFile(path string) error {
42 | bytes, err := os.ReadFile(path)
43 | if err != nil {
44 | return fmt.Errorf("读取配置文件失败: %v", err)
45 | }
46 |
47 | if err = initBasePath(path); err != nil {
48 | return fmt.Errorf("初始化 BasePath 失败: %v", err)
49 | }
50 |
51 | C = new(Config)
52 | if err := yaml.Unmarshal(bytes, C); err != nil {
53 | return fmt.Errorf("解析配置文件失败: %v", err)
54 | }
55 |
56 | cVal := reflect.ValueOf(C).Elem()
57 | for i := 0; i < cVal.NumField(); i++ {
58 | field := cVal.Field(i)
59 |
60 | // 为配置项初始化零值
61 | if field.Kind() == reflect.Ptr && field.IsNil() {
62 | elmType := field.Type().Elem()
63 | field.Set(reflect.New(elmType))
64 | }
65 |
66 | // 配置项初始化
67 | if i, ok := field.Interface().(Initializer); ok {
68 | if err := i.Init(); err != nil {
69 | return fmt.Errorf("初始化配置文件失败: %v", err)
70 | }
71 | }
72 | }
73 |
74 | return nil
75 | }
76 |
77 | // initBasePath 初始化 BasePath
78 | func initBasePath(path string) error {
79 | if filepath.IsAbs(path) {
80 | BasePath = filepath.Dir(path)
81 | return nil
82 | }
83 | absPath, err := filepath.Abs(path)
84 | if err != nil {
85 | return err
86 | }
87 | BasePath = filepath.Dir(absPath)
88 | return nil
89 | }
90 |
--------------------------------------------------------------------------------
/internal/config/emby.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/maps"
9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/randoms"
10 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs"
11 | )
12 |
13 | // PeStrategy 代理异常策略类型
14 | type PeStrategy string
15 |
16 | const (
17 | PeStrategyOrigin PeStrategy = "origin" // 回源
18 | PeStrategyReject PeStrategy = "reject" // 拒绝请求
19 | )
20 |
21 | // DlStrategy 下载策略类型
22 | type DlStrategy string
23 |
24 | const (
25 | DlStrategyOrigin DlStrategy = "origin" // 代理到源服务器
26 | DlStrategyDirect DlStrategy = "direct" // 获取并重定向到直链
27 | DlStrategy403 DlStrategy = "403" // 拒绝响应
28 | )
29 |
30 | // validPeStrategy 用于校验用户配置的策略是否合法
31 | var validPeStrategy = map[PeStrategy]struct{}{
32 | PeStrategyOrigin: {}, PeStrategyReject: {},
33 | }
34 |
35 | // validDlStrategy 用于校验用户配置的下载策略是否合法
36 | var validDlStrategy = map[DlStrategy]struct{}{
37 | DlStrategyOrigin: {}, DlStrategyDirect: {}, DlStrategy403: {},
38 | }
39 |
40 | // Emby 相关配置
41 | type Emby struct {
42 | // Emby 源服务器地址
43 | Host string `yaml:"host"`
44 | // rclone 或者 cd 的挂载目录
45 | MountPath string `yaml:"mount-path"`
46 | // EpisodesUnplayPrior 在获取剧集列表时是否将未播资源优先展示
47 | EpisodesUnplayPrior bool `yaml:"episodes-unplay-prior"`
48 | // ResortRandomItems 是否对随机的 items 进行重排序
49 | ResortRandomItems bool `yaml:"resort-random-items"`
50 | // ProxyErrorStrategy 代理错误时的处理策略
51 | ProxyErrorStrategy PeStrategy `yaml:"proxy-error-strategy"`
52 | // ImagesQuality 图片质量
53 | ImagesQuality int `yaml:"images-quality"`
54 | // Strm strm 配置
55 | Strm *Strm `yaml:"strm"`
56 | // DownloadStrategy 下载接口响应策略
57 | DownloadStrategy DlStrategy `yaml:"download-strategy"`
58 | // LocalMediaRoot 本地媒体根路径
59 | LocalMediaRoot string `yaml:"local-media-root"`
60 | }
61 |
62 | func (e *Emby) Init() error {
63 | if strs.AnyEmpty(e.Host) {
64 | return errors.New("emby.host 配置不能为空")
65 | }
66 | if strs.AnyEmpty(e.MountPath) {
67 | return errors.New("emby.mount-path 配置不能为空")
68 | }
69 | if strs.AnyEmpty(string(e.ProxyErrorStrategy)) {
70 | // 失败默认回源
71 | e.ProxyErrorStrategy = PeStrategyOrigin
72 | }
73 | if strs.AnyEmpty(string(e.DownloadStrategy)) {
74 | // 默认响应直链
75 | e.DownloadStrategy = DlStrategyDirect
76 | }
77 |
78 | e.ProxyErrorStrategy = PeStrategy(strings.TrimSpace(string(e.ProxyErrorStrategy)))
79 | if _, ok := validPeStrategy[e.ProxyErrorStrategy]; !ok {
80 | return fmt.Errorf("emby.proxy-error-strategy 配置错误, 有效值: %v", maps.Keys(validPeStrategy))
81 | }
82 |
83 | if e.ImagesQuality == 0 {
84 | // 不允许配置零值
85 | e.ImagesQuality = 70
86 | }
87 | if e.ImagesQuality < 0 || e.ImagesQuality > 100 {
88 | return fmt.Errorf("emby.images-quality 配置错误: %d, 允许配置范围: [1, 100]", e.ImagesQuality)
89 | }
90 |
91 | if e.Strm == nil {
92 | e.Strm = new(Strm)
93 | }
94 | if err := e.Strm.Init(); err != nil {
95 | return fmt.Errorf("emby.strm 配置错误: %v", err)
96 | }
97 |
98 | e.DownloadStrategy = DlStrategy(strings.TrimSpace(string(e.DownloadStrategy)))
99 | if _, ok := validDlStrategy[e.DownloadStrategy]; !ok {
100 | return fmt.Errorf("emby.download-strategy 配置错误, 有效值: %v", maps.Keys(validDlStrategy))
101 | }
102 |
103 | // 如果没有配置, 生成一个随机前缀, 避免将网盘资源误识别为本地
104 | if e.LocalMediaRoot = strings.TrimSpace(e.LocalMediaRoot); e.LocalMediaRoot == "" {
105 | e.LocalMediaRoot = "/" + randoms.RandomHex(32)
106 | }
107 |
108 | return nil
109 | }
110 |
111 | // Strm strm 配置
112 | type Strm struct {
113 | // PathMap 远程路径映射
114 | PathMap []string `yaml:"path-map"`
115 | // pathMap 配置初始化后转换为标准的 map 结构
116 | pathMap map[string]string
117 | }
118 |
119 | // Init 配置初始化
120 | func (s *Strm) Init() error {
121 | s.pathMap = make(map[string]string)
122 | for _, path := range s.PathMap {
123 | splits := strings.Split(path, "=>")
124 | if len(splits) != 2 {
125 | return fmt.Errorf("映射配置不规范: %s, 请使用 => 进行分割", path)
126 | }
127 | from, to := strings.TrimSpace(splits[0]), strings.TrimSpace(splits[1])
128 | s.pathMap[from] = to
129 | }
130 | return nil
131 | }
132 |
133 | // MapPath 将传入路径按照预配置的映射关系从上到下按顺序进行映射,
134 | // 至多成功映射一次
135 | func (s *Strm) MapPath(path string) string {
136 | for from, to := range s.pathMap {
137 | if strings.Contains(path, from) {
138 | return strings.Replace(path, from, to, 1)
139 | }
140 | }
141 | return path
142 | }
143 |
--------------------------------------------------------------------------------
/internal/config/log.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import "github.com/AmbitiousJun/go-emby2alist/internal/setup"
4 |
5 | // Log 日志配置
6 | type Log struct {
7 | DisableColor bool `yaml:"disable-color"` // 是否禁用彩色日志输出
8 | }
9 |
10 | // Init 配置初始化
11 | func (lc *Log) Init() error {
12 | setup.LogColorEnbale = !lc.DisableColor
13 | return nil
14 | }
15 |
--------------------------------------------------------------------------------
/internal/config/path.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strings"
7 |
8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors"
9 | )
10 |
11 | type Path struct {
12 | // Emby2Alist Emby 的路径前缀映射到 Alist 的路径前缀, 两个路径使用 : 符号隔开
13 | Emby2Alist []string `yaml:"emby2alist"`
14 |
15 | // emby2AlistArr 根据 Emby2Alist 转换成路径键值对数组
16 | emby2AlistArr [][2]string
17 | }
18 |
19 | func (p *Path) Init() error {
20 | p.emby2AlistArr = make([][2]string, 0)
21 | for _, e2a := range p.Emby2Alist {
22 | arr := strings.Split(e2a, ":")
23 | if len(arr) != 2 {
24 | return fmt.Errorf("path.emby2alist 配置错误, %s 无法根据 ':' 进行分割", e2a)
25 | }
26 | p.emby2AlistArr = append(p.emby2AlistArr, [2]string{arr[0], arr[1]})
27 | }
28 | return nil
29 | }
30 |
31 | // MapEmby2Alist 将 emby 路径映射成 alist 路径
32 | func (p *Path) MapEmby2Alist(embyPath string) (string, bool) {
33 | for _, cfg := range p.emby2AlistArr {
34 | ep, ap := cfg[0], cfg[1]
35 | if strings.HasPrefix(embyPath, ep) {
36 | log.Printf(colors.ToGray("命中 emby2alist 路径映射: %s => %s (如命中错误, 请将正确的映射配置前移)"), ep, ap)
37 | return strings.Replace(embyPath, ep, ap, 1), true
38 | }
39 | }
40 | return "", false
41 | }
42 |
--------------------------------------------------------------------------------
/internal/config/ssl.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 |
9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs"
10 | )
11 |
12 | // SslDir ssl 证书存放目录名称
13 | const SslDir = "ssl"
14 |
15 | type Ssl struct {
16 | Enable bool `yaml:"enable"` // 是否启用
17 | SinglePort bool `yaml:"single-port"` // 是否使用单一端口
18 | Key string `yaml:"key"` // 服务器私钥名称
19 | Crt string `yaml:"crt"` // 证书名称
20 | }
21 |
22 | func (s *Ssl) Init() error {
23 | if !s.Enable {
24 | return nil
25 | }
26 |
27 | if err := initSslDir(); err != nil {
28 | return fmt.Errorf("初始化 ssl 目录失败: %v", err)
29 | }
30 |
31 | // 非空校验
32 | if strs.AnyEmpty(s.Crt) {
33 | return errors.New("ssl.crt 配置不能为空")
34 | }
35 | if strs.AnyEmpty(s.Key) {
36 | return errors.New("ssl.key 配置不能为空")
37 | }
38 |
39 | // 判断证书密钥是否存在
40 | if stat, err := os.Stat(s.CrtPath()); err != nil || stat.IsDir() {
41 | return fmt.Errorf("检测不到证书文件, err: %v", err)
42 | }
43 | if stat, err := os.Stat(s.KeyPath()); err != nil || stat.IsDir() {
44 | return fmt.Errorf("检测不到密钥文件, err: %v", err)
45 | }
46 |
47 | return nil
48 | }
49 |
50 | // CrtPath 获取 cert 证书的绝对路径
51 | func (s *Ssl) CrtPath() string {
52 | return filepath.Join(BasePath, SslDir, s.Crt)
53 | }
54 |
55 | // KeyPath 获取密钥的绝对路径
56 | func (s *Ssl) KeyPath() string {
57 | return filepath.Join(BasePath, SslDir, s.Key)
58 | }
59 |
60 | // initSslDir 初始化 ssl 目录
61 | func initSslDir() error {
62 | absPath := filepath.Join(BasePath, SslDir)
63 | stat, err := os.Stat(absPath)
64 |
65 | // 目录已存在
66 | if err == nil && stat.IsDir() {
67 | return nil
68 | }
69 |
70 | return os.MkdirAll(absPath, os.ModeDir|os.ModePerm)
71 | }
72 |
--------------------------------------------------------------------------------
/internal/config/video_preview.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type VideoPreview struct {
4 | // Enable 是否开启网盘转码链接代理
5 | Enable bool `yaml:"enable"`
6 | // Containers 对哪些容器使用网盘转码链接代理
7 | Containers []string `yaml:"containers"`
8 | // IgnoreTemplateIds 忽略的转码清晰度
9 | IgnoreTemplateIds []string `yaml:"ignore-template-ids"`
10 |
11 | // containerMap 依据 Containers 初始化该 map, 便于后续快速判断
12 | containerMap map[string]struct{}
13 | // ignoreTemplateIdMap 依据 IgnoreTemplateIds 初始化该 map
14 | ignoreTemplateIdMap map[string]struct{}
15 | }
16 |
17 | func (vp *VideoPreview) Init() error {
18 | vp.containerMap = make(map[string]struct{})
19 | for _, container := range vp.Containers {
20 | vp.containerMap[container] = struct{}{}
21 | }
22 | vp.ignoreTemplateIdMap = make(map[string]struct{})
23 | for _, id := range vp.IgnoreTemplateIds {
24 | vp.ignoreTemplateIdMap[id] = struct{}{}
25 | }
26 | return nil
27 | }
28 |
29 | // ContainerValid 判断某个视频容器是否启用代理
30 | func (vp *VideoPreview) ContainerValid(container string) bool {
31 | _, ok := vp.containerMap[container]
32 | return ok
33 | }
34 |
35 | // IsTemplateIgnore 返回一个转码清晰度是否需要被忽略
36 | func (vp *VideoPreview) IsTemplateIgnore(templateId string) bool {
37 | _, ok := vp.ignoreTemplateIdMap[templateId]
38 | return ok
39 | }
40 |
--------------------------------------------------------------------------------
/internal/constant/constant.go:
--------------------------------------------------------------------------------
1 | package constant
2 |
3 | const (
4 | CurrentVersion = "v1.7.4"
5 | RepoAddr = "https://github.com/AmbitiousJun/go-emby2alist"
6 | )
7 |
8 | const (
9 | Reg_Socket = `(?i)^/.*(socket|embywebsocket)`
10 | Reg_PlaybackInfo = `(?i)^/.*items/.*/playbackinfo\??`
11 |
12 | Reg_PlayingStopped = `(?i)^/.*sessions/playing/stopped`
13 | Reg_PlayingProgress = `(?i)^/.*sessions/playing/progress`
14 |
15 | Reg_UserItems = `(?i)^/.*users/.*/items/\d+($|\?)`
16 | Reg_UserEpisodeItems = `(?i)^/.*users/.*/items\?.*includeitemtypes=(episode|movie)`
17 | Reg_UserItemsRandomResort = `(?i)^/.*users/.*/items\?.*SortBy=Random`
18 | Reg_UserItemsRandomWithLimit = `(?i)^/.*users/.*/items/with_limit\?.*SortBy=Random`
19 | Reg_UserPlayedItems = `(?i)^/.*users/.*/playeditems/(\d+)($|\?|/.*)?`
20 |
21 | Reg_ShowEpisodes = `(?i)^/.*shows/.*/episodes\??`
22 | Reg_VideoSubtitles = `(?i)^/.*videos/.*/subtitles`
23 |
24 | Reg_ResourceStream = `(?i)^/.*(videos|audio)/.*/(stream|universal)(\.\w+)?\??`
25 | Reg_ResourceMaster = `(?i)^/.*(videos|audio)/.*/(master)(\.\w+)?\??`
26 | Reg_ResourceMain = `(?i)^/.*(videos|audio)/.*/main.m3u8\??`
27 |
28 | Reg_ProxyPlaylist = `(?i)^/.*videos/proxy_playlist\??`
29 | Reg_ProxyTs = `(?i)^/.*videos/proxy_ts\??`
30 | Reg_ProxySubtitle = `(?i)^/.*videos/proxy_subtitle\??`
31 |
32 | Reg_ItemDownload = `(?i)^/.*items/\d+/download($|\?)`
33 | Reg_ItemSyncDownload = `(?i)^/.*sync/jobitems/\d+/file($|\?)`
34 |
35 | Reg_Images = `(?i)^/.*images`
36 | Reg_VideoModWebDefined = `(?i)^/web/modules/htmlvideoplayer/plugin.js`
37 | Reg_Proxy2Origin = `^/$|(?i)^.*(/web|/users|/artists|/genres|/similar|/shows|/system|/remote|/scheduledtasks)`
38 |
39 | Reg_IndexHtml = `(?i)^/web/index\.html`
40 | Route_CustomJs = `/ge2a/custom.js`
41 | Route_CustomCss = `/ge2a/custom.css`
42 |
43 | Reg_All = `.*`
44 | )
45 |
46 | const (
47 | RouteSubMatchGinKey = "routeSubMatches" // 路由匹配成功时, 会将匹配的正则结果存放到 Gin 上下文
48 |
49 | CustomJsDirName = "custom-js" // 自定义脚本存放目录
50 | CustomCssDirName = "custom-css" // 自定义样式存放目录
51 | )
52 |
--------------------------------------------------------------------------------
/internal/model/http.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | // HttpRes 通用 http 请求结果
4 | type HttpRes[T any] struct {
5 | Code int
6 | Data T
7 | Msg string
8 | }
9 |
--------------------------------------------------------------------------------
/internal/service/alist/api.go:
--------------------------------------------------------------------------------
1 | package alist
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/AmbitiousJun/go-emby2alist/internal/config"
9 | "github.com/AmbitiousJun/go-emby2alist/internal/model"
10 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors"
11 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https"
12 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons"
13 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs"
14 | )
15 |
16 | // FetchResource 请求 alist 资源 url 直链
17 | func FetchResource(fi FetchInfo) model.HttpRes[Resource] {
18 | if strs.AnyEmpty(fi.Path) {
19 | return model.HttpRes[Resource]{Code: http.StatusBadRequest, Msg: "参数 path 不能为空"}
20 | }
21 |
22 | if !fi.UseTranscode {
23 | // 请求原画资源
24 | res := FetchFsGet(fi.Path, fi.Header)
25 | if res.Code == http.StatusOK {
26 | if link, ok := res.Data.Attr("raw_url").String(); ok {
27 | return model.HttpRes[Resource]{Code: http.StatusOK, Data: Resource{Url: link}}
28 | }
29 | }
30 | if res.Msg == "" {
31 | res.Msg = fmt.Sprintf("未知异常, 原始响应: %v", jsons.NewByObj(res))
32 | }
33 | return model.HttpRes[Resource]{Code: res.Code, Msg: res.Msg}
34 | }
35 |
36 | // 转码资源请求失败后, 递归请求原画资源
37 | failedAndTryRaw := func(originRes model.HttpRes[*jsons.Item]) model.HttpRes[Resource] {
38 | if !fi.TryRawIfTranscodeFail {
39 | return model.HttpRes[Resource]{Code: originRes.Code, Msg: originRes.Msg}
40 | }
41 | log.Printf(colors.ToRed("请求转码资源失败, 尝试请求原画资源, 原始响应: %v"), jsons.NewByObj(originRes))
42 | fi.UseTranscode = false
43 | return FetchResource(fi)
44 | }
45 |
46 | // 请求转码资源
47 | res := FetchFsOther(fi.Path, fi.Header)
48 | if res.Code != http.StatusOK {
49 | return failedAndTryRaw(res)
50 | }
51 |
52 | list, ok := res.Data.Attr("video_preview_play_info").Attr("live_transcoding_task_list").Done()
53 | if !ok || list.Type() != jsons.JsonTypeArr {
54 | return failedAndTryRaw(res)
55 | }
56 | idx := list.FindIdx(func(val *jsons.Item) bool { return val.Attr("template_id").Val() == fi.Format })
57 | if idx == -1 {
58 | allFmts := list.Map(func(val *jsons.Item) any { return val.Attr("template_id").Val() })
59 | log.Printf(colors.ToRed("查找不到指定的格式: %s, 所有可用的格式: %v"), fi.Format, jsons.NewByArr(allFmts))
60 | return failedAndTryRaw(res)
61 | }
62 |
63 | link, ok := list.Idx(idx).Attr("url").String()
64 | if !ok {
65 | return failedAndTryRaw(res)
66 | }
67 |
68 | // 封装字幕链接, 封装失败也不返回失败
69 | subtitles := make([]SubtitleInfo, 0)
70 | subList, ok := res.Data.Attr("video_preview_play_info").Attr("live_transcoding_subtitle_task_list").Done()
71 | if ok {
72 | subList.RangeArr(func(_ int, value *jsons.Item) error {
73 | lang, _ := value.Attr("language").String()
74 | url, _ := value.Attr("url").String()
75 | subtitles = append(subtitles, SubtitleInfo{
76 | Lang: lang,
77 | Url: url,
78 | })
79 | return nil
80 | })
81 | }
82 |
83 | return model.HttpRes[Resource]{Code: http.StatusOK, Data: Resource{Url: link, Subtitles: subtitles}}
84 | }
85 |
86 | // FetchFsList 请求 alist "/api/fs/list" 接口
87 | //
88 | // 传入 path 与接口的 path 作用一致
89 | func FetchFsList(path string, header http.Header) model.HttpRes[*jsons.Item] {
90 | if strs.AnyEmpty(path) {
91 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "参数 path 不能为空"}
92 | }
93 | return Fetch("/api/fs/list", http.MethodPost, header, map[string]any{
94 | "refresh": true,
95 | "password": "",
96 | "path": path,
97 | })
98 | }
99 |
100 | // FetchFsGet 请求 alist "/api/fs/get" 接口
101 | //
102 | // 传入 path 与接口的 path 作用一致
103 | func FetchFsGet(path string, header http.Header) model.HttpRes[*jsons.Item] {
104 | if strs.AnyEmpty(path) {
105 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "参数 path 不能为空"}
106 | }
107 |
108 | return Fetch("/api/fs/get", http.MethodPost, header, map[string]any{
109 | "refresh": true,
110 | "password": "",
111 | "path": path,
112 | })
113 | }
114 |
115 | // FetchFsOther 请求 alist "/api/fs/other" 接口
116 | //
117 | // 传入 path 与接口的 path 作用一致
118 | func FetchFsOther(path string, header http.Header) model.HttpRes[*jsons.Item] {
119 | if strs.AnyEmpty(path) {
120 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "参数 path 不能为空"}
121 | }
122 |
123 | return Fetch("/api/fs/other", http.MethodPost, header, map[string]any{
124 | "method": "video_preview",
125 | "password": "",
126 | "path": path,
127 | })
128 | }
129 |
130 | // Fetch 请求 alist api
131 | func Fetch(uri, method string, header http.Header, body map[string]any) model.HttpRes[*jsons.Item] {
132 | host := config.C.Alist.Host
133 | token := config.C.Alist.Token
134 |
135 | // 1 发出请求
136 | if header == nil {
137 | header = make(http.Header)
138 | }
139 | header.Set("Content-Type", "application/json;charset=utf-8")
140 | header.Set("Authorization", token)
141 |
142 | resp, err := https.Request(method, host+uri, header, https.MapBody(body))
143 | if err != nil {
144 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "请求发送失败: " + err.Error()}
145 | }
146 | defer resp.Body.Close()
147 |
148 | // 2 封装响应
149 | result, err := jsons.Read(resp.Body)
150 | if err != nil {
151 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "解析响应体失败: " + err.Error()}
152 | }
153 |
154 | if code, ok := result.Attr("code").Int(); !ok || code != http.StatusOK {
155 | message, _ := result.Attr("message").String()
156 | return model.HttpRes[*jsons.Item]{Code: code, Msg: message}
157 | }
158 |
159 | if data, ok := result.Attr("data").Done(); ok {
160 | return model.HttpRes[*jsons.Item]{Code: http.StatusOK, Data: data}
161 | }
162 |
163 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "未知异常, result: " + result.String()}
164 | }
165 |
--------------------------------------------------------------------------------
/internal/service/alist/api_test.go:
--------------------------------------------------------------------------------
1 | package alist_test
2 |
3 | import (
4 | "log"
5 | "net/http"
6 | "testing"
7 |
8 | "github.com/AmbitiousJun/go-emby2alist/internal/config"
9 | "github.com/AmbitiousJun/go-emby2alist/internal/service/alist"
10 | )
11 |
12 | func TestFetch(t *testing.T) {
13 | err := config.ReadFromFile("../../../config.yml")
14 | if err != nil {
15 | t.Error(err)
16 | return
17 | }
18 | res := alist.Fetch("/api/fs/list", http.MethodPost, nil, map[string]any{
19 | "refresh": true,
20 | "password": "",
21 | "path": "/",
22 | })
23 | if res.Code == http.StatusOK {
24 | log.Println("请求成功, data: ", res.Data)
25 | } else {
26 | log.Println("请求失败, msg: ", res.Msg)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/internal/service/alist/path.go:
--------------------------------------------------------------------------------
1 | package alist
2 |
3 | import "encoding/base64"
4 |
5 | // PathEncode 将 alist 的资源原始路径进行编码, 防止路径在传输过程中出现错误
6 | func PathEncode(rawPath string) string {
7 | return base64.StdEncoding.EncodeToString([]byte(rawPath))
8 | }
9 |
10 | // PathDecode 对 alist 的编码路径进行解码, 返回原始路径
11 | //
12 | // 如果解码失败, 则返回原路径
13 | func PathDecode(encPath string) string {
14 | res, err := base64.StdEncoding.DecodeString(encPath)
15 | if err != nil {
16 | return encPath
17 | }
18 | return string(res)
19 | }
20 |
--------------------------------------------------------------------------------
/internal/service/alist/subtitle.go:
--------------------------------------------------------------------------------
1 | package alist
2 |
3 | var (
4 |
5 | // langDisplayNames 将 alist 的字幕代码转换成对应名称
6 | langDisplayNames = map[string]string{
7 | "chi": "简体中文",
8 | "eng": "English",
9 | "jpn": "日本語",
10 | }
11 | )
12 |
13 | // SubLangDisplayName 将 lang 转换成对应名称
14 | func SubLangDisplayName(lang string) string {
15 | if name, ok := langDisplayNames[lang]; ok {
16 | return name
17 | }
18 | return lang
19 | }
20 |
--------------------------------------------------------------------------------
/internal/service/alist/type.go:
--------------------------------------------------------------------------------
1 | package alist
2 |
3 | import "net/http"
4 |
5 | // FetchInfo 请求 alist 资源需要的参数信息
6 | type FetchInfo struct {
7 | Path string // alist 资源绝对路径
8 | UseTranscode bool // 是否请求转码资源 (只支持视频资源)
9 | Format string // 要请求的转码资源格式, 如: FHD
10 | TryRawIfTranscodeFail bool // 如果请求转码资源失败, 是否尝试请求原画资源
11 | Header http.Header // 自定义的请求头
12 | }
13 |
14 | // Resource alist 资源信息封装
15 | type Resource struct {
16 | Url string // 资源远程路径
17 | Subtitles []SubtitleInfo // 字幕信息
18 | }
19 |
20 | // SubtitleInfo 资源内嵌的字幕信息
21 | type SubtitleInfo struct {
22 | Lang string // 字幕语言
23 | Url string // 字幕远程路径
24 | }
25 |
--------------------------------------------------------------------------------
/internal/service/emby/api.go:
--------------------------------------------------------------------------------
1 | package emby
2 |
3 | import (
4 | "errors"
5 | "io"
6 | "net/http"
7 |
8 | "github.com/AmbitiousJun/go-emby2alist/internal/config"
9 | "github.com/AmbitiousJun/go-emby2alist/internal/model"
10 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https"
11 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons"
12 |
13 | "github.com/gin-gonic/gin"
14 | )
15 |
16 | // proxyAndSetRespHeader 代理 emby 接口
17 | // 返回响应内容, 并将响应头写入 c
18 | //
19 | // 如果请求是失败的响应, 会直接返回客户端, 并在第二个参数中返回 false
20 | func proxyAndSetRespHeader(c *gin.Context) (model.HttpRes[*jsons.Item], bool) {
21 | c.Request.Header.Del("Accept-Encoding")
22 | res, respHeader := RawFetch(c.Request.URL.String(), c.Request.Method, c.Request.Header, c.Request.Body)
23 | if res.Code != http.StatusOK {
24 | checkErr(c, errors.New(res.Msg))
25 | return res, false
26 | }
27 | https.CloneHeader(c, respHeader)
28 | return res, true
29 | }
30 |
31 | // Fetch 请求 emby api 接口, 使用 map 请求体
32 | func Fetch(uri, method string, header http.Header, body map[string]any) (model.HttpRes[*jsons.Item], http.Header) {
33 | return RawFetch(uri, method, header, https.MapBody(body))
34 | }
35 |
36 | // RawFetch 请求 emby api 接口, 使用流式请求体
37 | func RawFetch(uri, method string, header http.Header, body io.ReadCloser) (model.HttpRes[*jsons.Item], http.Header) {
38 | u := config.C.Emby.Host + uri
39 |
40 | // 构造请求头, 发出请求
41 | if header == nil {
42 | header = make(http.Header)
43 | }
44 | if header.Get("Content-Type") == "" {
45 | header.Set("Content-Type", "application/json;charset=utf-8")
46 | }
47 |
48 | resp, err := https.Request(method, u, header, body)
49 | if err != nil {
50 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "请求发送失败: " + err.Error()}, nil
51 | }
52 | defer resp.Body.Close()
53 |
54 | // 读取响应
55 | result, err := jsons.Read(resp.Body)
56 | if err != nil {
57 | return model.HttpRes[*jsons.Item]{Code: http.StatusBadRequest, Msg: "解析响应失败: " + err.Error()}, nil
58 | }
59 | return model.HttpRes[*jsons.Item]{Code: http.StatusOK, Data: result}, resp.Header
60 | }
61 |
--------------------------------------------------------------------------------
/internal/service/emby/auth.go:
--------------------------------------------------------------------------------
1 | package emby
2 |
3 | import (
4 | "io"
5 | "log"
6 | "net/http"
7 | "regexp"
8 | "strings"
9 | "sync"
10 |
11 | "github.com/AmbitiousJun/go-emby2alist/internal/config"
12 | "github.com/AmbitiousJun/go-emby2alist/internal/constant"
13 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors"
14 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https"
15 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs"
16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls"
17 |
18 | "github.com/gin-gonic/gin"
19 | )
20 |
21 | // AuthUri 鉴权地址
22 | //
23 | // 通过此 uri, 可以判断出客户端传递的 api_key 是否是被 emby 服务器认可的
24 | const AuthUri = "/emby/Auth/Keys"
25 |
26 | // validApiKeys 已经校验通过的 api_key, 下次就不再校验
27 | //
28 | // 这个 map 不会进行大小限制, 考虑到 emby 原服务器中合法的 api_key 个数不是无限个
29 | // 所以这里也不用限制太多
30 | var validApiKeys = sync.Map{}
31 |
32 | // ApiKeyType 标记 emby 支持的不同种 api_key 传递方式
33 | type ApiKeyType string
34 |
35 | const (
36 | Query ApiKeyType = "query" // query 参数中的 api_key
37 | Header ApiKeyType = "header" // 请求头中的 Authorization
38 | )
39 |
40 | const (
41 | QueryApiKeyName = "api_key"
42 | QueryTokenName = "X-Emby-Token"
43 | HeaderAuthName = "Authorization"
44 | HeaderFullAuthName = "X-Emby-Authorization"
45 | )
46 |
47 | const UnauthorizedResp = "Access token is invalid or expired."
48 |
49 | // ApiKeyChecker 对指定的 api 进行鉴权
50 | //
51 | // 该中间件会将客户端传递的 api_key 发送给 emby 服务器, 如果 emby 返回 401 异常
52 | // 说明这个 api_key 是客户端伪造的, 阻断客户端的请求
53 | func ApiKeyChecker() gin.HandlerFunc {
54 |
55 | patterns := []*regexp.Regexp{
56 | regexp.MustCompile(constant.Reg_ResourceStream),
57 | regexp.MustCompile(constant.Reg_PlaybackInfo),
58 | regexp.MustCompile(constant.Reg_ItemDownload),
59 | regexp.MustCompile(constant.Reg_ItemSyncDownload),
60 | regexp.MustCompile(constant.Reg_VideoSubtitles),
61 | regexp.MustCompile(constant.Reg_ProxyPlaylist),
62 | regexp.MustCompile(constant.Reg_ProxyTs),
63 | regexp.MustCompile(constant.Reg_ProxySubtitle),
64 | regexp.MustCompile(constant.Reg_ShowEpisodes),
65 | regexp.MustCompile(constant.Reg_UserItems),
66 | }
67 |
68 | return func(c *gin.Context) {
69 | // 1 取出 api_key
70 | kType, kName, apiKey := getApiKey(c)
71 |
72 | // 2 如果该 key 已经是被信任的, 跳过校验
73 | if _, ok := validApiKeys.Load(apiKey); ok {
74 | return
75 | }
76 |
77 | // 3 判断当前请求的 uri 是否需要被校验
78 | needCheck := false
79 | for _, pattern := range patterns {
80 | if pattern.MatchString(c.Request.RequestURI) {
81 | needCheck = true
82 | break
83 | }
84 | }
85 | if !needCheck {
86 | return
87 | }
88 |
89 | // 4 发出请求, 验证 api_key
90 | u := config.C.Emby.Host + AuthUri
91 | var header http.Header
92 | if kType == Query {
93 | u = urls.AppendArgs(u, kName, apiKey)
94 | } else {
95 | header = make(http.Header)
96 | header.Set(kName, apiKey)
97 | }
98 | resp, err := https.Request(http.MethodGet, u, header, nil)
99 | if err != nil {
100 | log.Printf(colors.ToRed("鉴权失败: %v"), err)
101 | c.Abort()
102 | return
103 | }
104 | defer resp.Body.Close()
105 | bodyBytes, err := io.ReadAll(resp.Body)
106 | if err != nil {
107 | log.Printf(colors.ToRed("鉴权中间件读取源服务器响应失败: %v"), err)
108 | bodyBytes = []byte(UnauthorizedResp)
109 | }
110 | respBody := strings.TrimSpace(string(bodyBytes))
111 |
112 | // 5 判断是否被源服务器拒绝
113 | if resp.StatusCode == http.StatusUnauthorized && respBody == UnauthorizedResp {
114 | c.String(http.StatusUnauthorized, "鉴权失败")
115 | c.Abort()
116 | return
117 | }
118 |
119 | // 6 校验通过, 加入信任集合
120 | validApiKeys.Store(apiKey, struct{}{})
121 | }
122 | }
123 |
124 | // getApiKey 获取请求中的 api_key 信息
125 | func getApiKey(c *gin.Context) (keyType ApiKeyType, keyName string, apiKey string) {
126 | if c == nil {
127 | return Query, "", ""
128 | }
129 |
130 | keyName = QueryApiKeyName
131 | keyType = Query
132 | apiKey = c.Query(keyName)
133 | if strs.AllNotEmpty(apiKey) {
134 | return
135 | }
136 |
137 | keyName = QueryTokenName
138 | apiKey = c.Query(keyName)
139 | if strs.AllNotEmpty(apiKey) {
140 | return
141 | }
142 |
143 | keyType = Header
144 | apiKey = c.GetHeader(keyName)
145 | if strs.AllNotEmpty(apiKey) {
146 | return
147 | }
148 |
149 | keyName = HeaderAuthName
150 | apiKey = c.GetHeader(keyName)
151 | if strs.AllNotEmpty(apiKey) {
152 | return
153 | }
154 |
155 | keyName = HeaderFullAuthName
156 | apiKey = c.GetHeader(keyName)
157 | if strs.AllNotEmpty(apiKey) {
158 | return
159 | }
160 |
161 | return
162 | }
163 |
--------------------------------------------------------------------------------
/internal/service/emby/cors.go:
--------------------------------------------------------------------------------
1 | package emby
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "net/http"
7 |
8 | "github.com/AmbitiousJun/go-emby2alist/internal/config"
9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https"
10 | "github.com/gin-gonic/gin"
11 | )
12 |
13 | // ChangeBaseVideoModuleCorsDefined 调整 emby 的播放器 cors 配置, 使其支持跨域播放
14 | func ChangeBaseVideoModuleCorsDefined(c *gin.Context) {
15 | // 1 代理请求
16 | embyHost := config.C.Emby.Host
17 | resp, err := https.Request(c.Request.Method, embyHost+c.Request.URL.String(), nil, c.Request.Body)
18 | if checkErr(c, err) {
19 | return
20 | }
21 | if resp.StatusCode != http.StatusOK {
22 | checkErr(c, fmt.Errorf("emby 返回非预期状态码: %d", resp.StatusCode))
23 | return
24 | }
25 | resp.Header.Del("Content-Length")
26 | defer resp.Body.Close()
27 |
28 | // 2 读取原始响应
29 | bodyBytes, err := io.ReadAll(resp.Body)
30 | if checkErr(c, err) {
31 | return
32 | }
33 |
34 | // 3 注入 JS 代码补丁
35 | modObj := `window.defined['modules/htmlvideoplayer/plugin.js']`
36 | modObjDefault := modObj + ".default"
37 | modObjPrototype := modObjDefault + ".prototype"
38 | modObjCorsFunc := modObjPrototype + ".getCrossOriginValue"
39 | jsScript := fmt.Sprintf(`(function(){ var modFunc; modFunc = function(){if(!%s||!%s||!%s||!%s){console.log('emby 未初始化完成...');setTimeout(modFunc);return;}%s=function(mediaSource,playMethod){return null;};console.log('cors 脚本补丁已注入')}; modFunc() })()`, modObj, modObjDefault, modObjPrototype, modObjCorsFunc, modObjCorsFunc)
40 |
41 | c.Status(http.StatusOK)
42 | https.CloneHeader(c, resp.Header)
43 | c.Writer.Write(append(bodyBytes, []byte(jsScript)...))
44 | c.Writer.Flush()
45 | }
46 |
--------------------------------------------------------------------------------
/internal/service/emby/custom_cssjs.go:
--------------------------------------------------------------------------------
1 | package emby
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "log"
7 | "net/http"
8 | "net/url"
9 | "os"
10 | "path/filepath"
11 | "strings"
12 | "sync"
13 |
14 | "github.com/AmbitiousJun/go-emby2alist/internal/config"
15 | "github.com/AmbitiousJun/go-emby2alist/internal/constant"
16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors"
17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https"
18 | "github.com/gin-gonic/gin"
19 | "golang.org/x/sync/errgroup"
20 | )
21 |
22 | // customJsList 首次访问时, 将所有自定义脚本预加载在内存中
23 | var customJsList = []string{}
24 |
25 | // customCssList 首次访问时, 将所有自定义样式预加载在内存中
26 | var customCssList = []string{}
27 |
28 | // loadAllCustomCssJs 加载所有自定义脚本
29 | var loadAllCustomCssJs = sync.OnceFunc(func() {
30 | loadRemoteContent := func(originBytes []byte) ([]byte, error) {
31 | if len(originBytes) == 0 {
32 | return []byte{}, nil
33 | }
34 |
35 | str := strings.TrimSpace(string(originBytes))
36 | u, err := url.Parse(str)
37 | if err != nil {
38 | // 非远程地址
39 | return originBytes, nil
40 | }
41 |
42 | resp, err := https.Request(http.MethodGet, u.String(), nil, nil)
43 | if err != nil {
44 | return nil, fmt.Errorf("远程加载失败: %s, err: %v", u.String(), err)
45 | }
46 | defer resp.Body.Close()
47 | if !https.IsSuccessCode(resp.StatusCode) {
48 | return nil, fmt.Errorf("远程错误响应: %s, err: %s", u.String(), resp.Status)
49 | }
50 |
51 | bytes, err := io.ReadAll(resp.Body)
52 | if err != nil {
53 | return nil, fmt.Errorf("远程读取失败: %s, err: %v", u.String(), err)
54 | }
55 |
56 | return bytes, nil
57 | }
58 |
59 | loadFiles := func(fp, ext, successLogPrefix string) ([]string, error) {
60 | if err := os.MkdirAll(fp, os.ModePerm); err != nil {
61 | return nil, fmt.Errorf("目录初始化失败: %s, err: %v", fp, err)
62 | }
63 |
64 | files, err := os.ReadDir(fp)
65 | if err != nil {
66 | return nil, fmt.Errorf("读取目录失败: %s, err: %v, 无法注入自定义脚本", fp, err)
67 | }
68 |
69 | res := []string{}
70 | ch := make(chan string)
71 | chg := new(errgroup.Group)
72 | chg.Go(func() error {
73 | for content := range ch {
74 | res = append(res, content)
75 | }
76 | return nil
77 | })
78 |
79 | g := new(errgroup.Group)
80 | for _, file := range files {
81 | if file.IsDir() {
82 | continue
83 | }
84 |
85 | if filepath.Ext(file.Name()) != ext {
86 | continue
87 | }
88 |
89 | g.Go(func() error {
90 | content, err := os.ReadFile(filepath.Join(fp, file.Name()))
91 | if err != nil {
92 | return fmt.Errorf("读取文件失败: %s, err: %v", file.Name(), err)
93 | }
94 |
95 | // 支持远程加载
96 | content, err = loadRemoteContent(content)
97 | if err != nil {
98 | return fmt.Errorf("远程加载失败: %s, err: %v", file.Name(), err)
99 | }
100 |
101 | ch <- string(content)
102 | log.Printf(colors.ToGreen("%s已加载: %s"), successLogPrefix, file.Name())
103 | return nil
104 | })
105 |
106 | }
107 |
108 | if err := g.Wait(); err != nil {
109 | close(ch)
110 | return nil, err
111 | }
112 | close(ch)
113 | chg.Wait()
114 | return res, nil
115 | }
116 |
117 | fp := filepath.Join(config.BasePath, constant.CustomJsDirName)
118 | jsList, err := loadFiles(fp, ".js", "自定义脚本")
119 | if err != nil {
120 | log.Printf(colors.ToRed("加载自定义脚本异常: %v"), err)
121 | return
122 | }
123 | customJsList = append(customJsList, jsList...)
124 |
125 | fp = filepath.Join(config.BasePath, constant.CustomCssDirName)
126 | cssList, err := loadFiles(fp, ".css", "自定义样式表")
127 | if err != nil {
128 | log.Printf(colors.ToRed("加载自定义样式表异常: %v"), err)
129 | return
130 | }
131 | customCssList = append(customCssList, cssList...)
132 | })
133 |
134 | // ProxyIndexHtml 代理 index.html 注入自定义脚本样式文件
135 | func ProxyIndexHtml(c *gin.Context) {
136 | embyHost := config.C.Emby.Host
137 | resp, err := https.Request(c.Request.Method, embyHost+c.Request.URL.String(), c.Request.Header, c.Request.Body)
138 | if checkErr(c, err) {
139 | return
140 | }
141 | defer resp.Body.Close()
142 |
143 | if !https.IsSuccessCode(resp.StatusCode) && checkErr(c, err) {
144 | return
145 | }
146 |
147 | bodyBytes, err := io.ReadAll(resp.Body)
148 | if checkErr(c, err) {
149 | return
150 | }
151 |
152 | content := string(bodyBytes)
153 | bodyCloseTag := "