├── .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 | ![路径映射示例](assets/2024-09-05-17-20-23.png) 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 | Star History Chart 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 := "" 154 | customJsElm := fmt.Sprintf(` `, constant.Route_CustomJs) 155 | content = strings.ReplaceAll(content, bodyCloseTag, customJsElm+"\n"+bodyCloseTag) 156 | 157 | customCssElm := fmt.Sprintf(` `, constant.Route_CustomCss) 158 | content = strings.ReplaceAll(content, bodyCloseTag, customCssElm+"\n"+bodyCloseTag) 159 | 160 | c.Status(resp.StatusCode) 161 | resp.Header.Del("Content-Length") 162 | https.CloneHeader(c, resp.Header) 163 | c.Writer.Write([]byte(content)) 164 | c.Writer.Flush() 165 | } 166 | 167 | // ProxyCustomJs 代理自定义脚本 168 | func ProxyCustomJs(c *gin.Context) { 169 | loadAllCustomCssJs() 170 | 171 | contentBuilder := strings.Builder{} 172 | for _, script := range customJsList { 173 | contentBuilder.WriteString(fmt.Sprintf("(function(){ %s })();\n", script)) 174 | } 175 | contentBytes := []byte(contentBuilder.String()) 176 | 177 | c.Status(http.StatusOK) 178 | c.Header("Content-Type", "application/javascript") 179 | c.Header("Content-Length", fmt.Sprintf("%d", len(contentBytes))) 180 | c.Header("Cache-Control", "no-store") 181 | c.Header("Pragma", "no-cache") 182 | c.Header("Expires", "0") 183 | c.Writer.Write(contentBytes) 184 | c.Writer.Flush() 185 | } 186 | 187 | // ProxyCustomCss 代理自定义样式表 188 | func ProxyCustomCss(c *gin.Context) { 189 | loadAllCustomCssJs() 190 | 191 | contentBuilder := strings.Builder{} 192 | for _, style := range customCssList { 193 | contentBuilder.WriteString(fmt.Sprintf("%s\n\n\n", style)) 194 | } 195 | contentBytes := []byte(contentBuilder.String()) 196 | 197 | c.Status(http.StatusOK) 198 | c.Header("Content-Type", "text/css") 199 | c.Header("Content-Length", fmt.Sprintf("%d", len(contentBytes))) 200 | c.Header("Cache-Control", "no-store") 201 | c.Header("Pragma", "no-cache") 202 | c.Header("Expires", "0") 203 | c.Writer.Write(contentBytes) 204 | c.Writer.Flush() 205 | } 206 | -------------------------------------------------------------------------------- /internal/service/emby/download.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/url" 10 | "regexp" 11 | "strconv" 12 | 13 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 14 | "github.com/AmbitiousJun/go-emby2alist/internal/constant" 15 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 18 | "github.com/gin-gonic/gin" 19 | ) 20 | 21 | // HandleSyncDownload 处理 Sync 下载接口, 重定向到直链 22 | func HandleSyncDownload(c *gin.Context) { 23 | // 解析出 JobItems id 24 | itemInfo, err := resolveItemInfo(c) 25 | if checkErr(c, err) { 26 | return 27 | } 28 | log.Printf(colors.ToBlue("解析出来的 itemInfo 信息: %v"), jsons.NewByVal(itemInfo)) 29 | if itemInfo.Id == "" { 30 | checkErr(c, errors.New("JobItems id 为空")) 31 | return 32 | } 33 | 34 | // 请求 targets 列表 35 | targetUri := "/Sync/Targets?api_key=" + itemInfo.ApiKey 36 | resp, _ := Fetch(targetUri, http.MethodGet, nil, nil) 37 | if resp.Code != http.StatusOK { 38 | checkErr(c, fmt.Errorf("请求 emby 失败: %v, uri: %s", resp.Msg, targetUri)) 39 | return 40 | } 41 | targets := resp.Data 42 | if targets.Empty() { 43 | checkErr(c, fmt.Errorf("targets 列表为空, 原始响应: %v", targets)) 44 | return 45 | } 46 | 47 | // 每个 id 逐一尝试 48 | readyUriTmpl := "/Sync/Items/Ready?api_key=" + itemInfo.ApiKey + "&TargetId=" 49 | targets.RangeArr(func(_ int, target *jsons.Item) error { 50 | id, ok := target.Attr("Id").String() 51 | if !ok { 52 | return nil 53 | } 54 | 55 | // 请求 Ready 接口 56 | readyUri := readyUriTmpl + id 57 | resp, _ := Fetch(readyUri, http.MethodGet, nil, nil) 58 | if resp.Code != http.StatusOK { 59 | checkErr(c, fmt.Errorf("请求 emby 失败: %v, uri: %s", resp.Msg, readyUri)) 60 | return jsons.ErrBreakRange 61 | } 62 | readyItems := resp.Data 63 | if readyItems.Empty() { 64 | return nil 65 | } 66 | 67 | // 遍历所有 item 68 | breakRange := false 69 | readyItems.RangeArr(func(_ int, ri *jsons.Item) error { 70 | jobId, ok := ri.Attr("SyncJobItemId").Int() 71 | if !ok { 72 | return nil 73 | } 74 | if strconv.Itoa(jobId) != itemInfo.Id { 75 | return nil 76 | } 77 | 78 | // 匹配成功, 获取到下载项目的 ItemId, 重新封装请求, 进行直链重定向 79 | itemId, ok := ri.Attr("Item").Attr("Id").String() 80 | if !ok { 81 | checkErr(c, fmt.Errorf("解析 emby 响应异常: 获取不到 itemId, 原始响应: %v", ri)) 82 | breakRange = true 83 | return jsons.ErrBreakRange 84 | } 85 | msId, ok := ri.Attr("Item").Attr("MediaSources").Idx(0).Attr("Id").String() 86 | if !ok { 87 | checkErr(c, fmt.Errorf("解析 emby 响应异常: 获取不到 mediaSourceId, 原始响应: %v", ri)) 88 | breakRange = true 89 | return jsons.ErrBreakRange 90 | } 91 | log.Printf(colors.ToGreen("成功匹配到 itemId: %s, mediaSourceId: %s"), itemId, msId) 92 | 93 | newUrl, _ := url.Parse(fmt.Sprintf("/videos/%s/stream?MediaSourceId=%s&api_key=%s&Static=true", itemId, msId, itemInfo.ApiKey)) 94 | c.Redirect(http.StatusTemporaryRedirect, newUrl.String()) 95 | breakRange = true 96 | return jsons.ErrBreakRange 97 | }) 98 | 99 | if breakRange { 100 | return jsons.ErrBreakRange 101 | } 102 | 103 | return nil 104 | }) 105 | 106 | } 107 | 108 | // DownloadStrategyChecker 拦截下载请求, 并根据配置的策略进行响应 109 | func DownloadStrategyChecker() gin.HandlerFunc { 110 | 111 | var downloadRoutes = []*regexp.Regexp{ 112 | regexp.MustCompile(constant.Reg_ItemDownload), 113 | regexp.MustCompile(constant.Reg_ItemSyncDownload), 114 | } 115 | 116 | return func(c *gin.Context) { 117 | // 放行非下载接口 118 | var flag bool 119 | for _, route := range downloadRoutes { 120 | if route.MatchString(c.Request.RequestURI) { 121 | flag = true 122 | break 123 | } 124 | } 125 | if !flag { 126 | return 127 | } 128 | 129 | strategy := config.C.Emby.DownloadStrategy 130 | 131 | if strategy == config.DlStrategyDirect { 132 | return 133 | } 134 | defer c.Abort() 135 | 136 | if strategy == config.DlStrategy403 { 137 | c.String(http.StatusForbidden, "下载接口已禁用") 138 | return 139 | } 140 | 141 | if strategy == config.DlStrategyOrigin { 142 | remote := config.C.Emby.Host + c.Request.URL.String() 143 | resp, err := https.Request(c.Request.Method, remote, c.Request.Header, c.Request.Body) 144 | if checkErr(c, err) { 145 | return 146 | } 147 | defer resp.Body.Close() 148 | c.Status(resp.StatusCode) 149 | https.CloneHeader(c, resp.Header) 150 | io.Copy(c.Writer, resp.Body) 151 | } 152 | 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /internal/service/emby/emby.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "net/http" 8 | "net/http/httputil" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 15 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 18 | "github.com/AmbitiousJun/go-emby2alist/internal/web/webport" 19 | 20 | "github.com/gin-gonic/gin" 21 | ) 22 | 23 | // NoRedirectClients 不使用重定向的客户端 24 | var NoRedirectClients = map[string]struct{}{ 25 | "Emby for iOS": {}, 26 | "Emby for macOS": {}, 27 | "Emby for Android": {}, 28 | } 29 | 30 | func ProxySocket() func(*gin.Context) { 31 | 32 | var proxy *httputil.ReverseProxy 33 | var once = sync.Once{} 34 | 35 | initFunc := func() { 36 | origin := config.C.Emby.Host 37 | u, err := url.Parse(origin) 38 | if err != nil { 39 | panic("转换 emby host 异常: " + err.Error()) 40 | } 41 | 42 | proxy = httputil.NewSingleHostReverseProxy(u) 43 | 44 | proxy.Director = func(r *http.Request) { 45 | r.URL.Scheme = u.Scheme 46 | r.URL.Host = u.Host 47 | } 48 | } 49 | 50 | return func(c *gin.Context) { 51 | once.Do(initFunc) 52 | proxy.ServeHTTP(c.Writer, c.Request) 53 | } 54 | } 55 | 56 | // HandleImages 处理图片请求 57 | // 58 | // 修改图片质量参数为配置值 59 | func HandleImages(c *gin.Context) { 60 | q := c.Request.URL.Query() 61 | q.Del("quality") 62 | q.Del("Quality") 63 | q.Set("Quality", strconv.Itoa(config.C.Emby.ImagesQuality)) 64 | c.Request.URL.RawQuery = q.Encode() 65 | ProxyOrigin(c) 66 | } 67 | 68 | // ProxyOrigin 将请求代理到源服务器 69 | func ProxyOrigin(c *gin.Context) { 70 | if c == nil { 71 | return 72 | } 73 | origin := config.C.Emby.Host 74 | if err := https.ProxyRequest(c, origin, true); err != nil { 75 | log.Printf(colors.ToRed("代理异常: %v"), err) 76 | } 77 | } 78 | 79 | // TestProxyUri 用于测试的代理, 80 | // 主要是为了查看实际请求的详细信息, 方便测试 81 | func TestProxyUri(c *gin.Context) bool { 82 | testUris := []string{} 83 | 84 | flag := false 85 | for _, uri := range testUris { 86 | if strings.Contains(c.Request.RequestURI, uri) { 87 | flag = true 88 | break 89 | } 90 | } 91 | if !flag { 92 | return false 93 | } 94 | 95 | type TestInfos struct { 96 | Uri string 97 | Method string 98 | Header map[string]string 99 | Body string 100 | RespStatus int 101 | RespHeader map[string]string 102 | RespBody string 103 | } 104 | 105 | infos := &TestInfos{ 106 | Uri: c.Request.URL.String(), 107 | Method: c.Request.Method, 108 | Header: make(map[string]string), 109 | RespHeader: make(map[string]string), 110 | } 111 | 112 | for key, values := range c.Request.Header { 113 | infos.Header[key] = strings.Join(values, "|") 114 | } 115 | 116 | bodyBytes, err := io.ReadAll(c.Request.Body) 117 | if err != nil { 118 | log.Printf(colors.ToRed("测试 uri 执行异常: %v"), err) 119 | return false 120 | } 121 | infos.Body = string(bodyBytes) 122 | 123 | origin := config.C.Emby.Host 124 | resp, err := https.Request(infos.Method, origin+infos.Uri, c.Request.Header, io.NopCloser(bytes.NewBuffer(bodyBytes))) 125 | if err != nil { 126 | log.Printf(colors.ToRed("测试 uri 执行异常: %v"), err) 127 | return false 128 | } 129 | defer resp.Body.Close() 130 | 131 | for key, values := range resp.Header { 132 | infos.RespHeader[key] = strings.Join(values, "|") 133 | for _, value := range values { 134 | c.Writer.Header().Add(key, value) 135 | } 136 | } 137 | 138 | bodyBytes, err = io.ReadAll(resp.Body) 139 | if err != nil { 140 | log.Printf(colors.ToRed("测试 uri 执行异常: %v"), err) 141 | return false 142 | } 143 | infos.RespBody = string(bodyBytes) 144 | infos.RespStatus = resp.StatusCode 145 | log.Printf(colors.ToYellow("测试 uri 代理信息: %s"), jsons.NewByVal(infos)) 146 | 147 | c.Status(infos.RespStatus) 148 | c.Writer.Write(bodyBytes) 149 | 150 | return true 151 | } 152 | 153 | // RedirectOrigin 将 GET 请求 308 重定向到源服务器 154 | // 其他请求走本地代理 155 | func RedirectOrigin(c *gin.Context) { 156 | if c == nil { 157 | return 158 | } 159 | 160 | if c.Request.Method != http.MethodGet { 161 | ProxyOrigin(c) 162 | return 163 | } 164 | 165 | if _, ok := NoRedirectClients[c.Query("X-Emby-Client")]; ok { 166 | ProxyOrigin(c) 167 | return 168 | } 169 | 170 | port, exist := c.Get(webport.GinKey) 171 | if config.C.Ssl.Enable && (exist && port == webport.HTTPS) { 172 | // https 只能走代理 173 | ProxyOrigin(c) 174 | return 175 | } 176 | 177 | origin := config.C.Emby.Host 178 | c.Redirect(http.StatusPermanentRedirect, origin+c.Request.URL.String()) 179 | } 180 | -------------------------------------------------------------------------------- /internal/service/emby/episode.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | // ResortEpisodes 代理剧集列表请求 15 | // 16 | // 如果开启了 emby.episodes-unplay-prior 配置, 17 | // 则会将未播剧集排在前面位置 18 | func ResortEpisodes(c *gin.Context) { 19 | // 1 检查配置是否开启 20 | if !config.C.Emby.EpisodesUnplayPrior { 21 | checkErr(c, https.ProxyRequest(c, config.C.Emby.Host, true)) 22 | return 23 | } 24 | 25 | // 2 去除分页限制 26 | q := c.Request.URL.Query() 27 | q.Del("Limit") 28 | q.Del("StartIndex") 29 | c.Request.URL.RawQuery = q.Encode() 30 | 31 | // 3 代理请求 32 | c.Request.Header.Del("Accept-Encoding") 33 | res, respHeader := RawFetch(c.Request.URL.String(), c.Request.Method, c.Request.Header, c.Request.Body) 34 | if res.Code != http.StatusOK { 35 | checkErr(c, errors.New(res.Msg)) 36 | return 37 | } 38 | resJson := res.Data 39 | https.CloneHeader(c, respHeader) 40 | defer func() { 41 | jsons.OkResp(c, resJson) 42 | }() 43 | 44 | // 4 处理数据 45 | items, ok := resJson.Attr("Items").Done() 46 | if !ok || items.Type() != jsons.JsonTypeArr { 47 | return 48 | } 49 | playedItems, allItems := make([]*jsons.Item, 0), make([]*jsons.Item, 0) 50 | items.RangeArr(func(_ int, value *jsons.Item) error { 51 | if len(allItems) > 0 { 52 | // 找到第一个未播的剧集之后, 剩余剧集都当作是未播的 53 | allItems = append(allItems, value) 54 | return nil 55 | } 56 | 57 | if played, ok := value.Attr("UserData").Attr("Played").Bool(); ok && played { 58 | playedItems = append(playedItems, value) 59 | return nil 60 | } 61 | 62 | allItems = append(allItems, value) 63 | return nil 64 | }) 65 | 66 | // 将已播的数据放在末尾 67 | allItems = append(allItems, playedItems...) 68 | 69 | resJson.Put("Items", jsons.NewByVal(allItems)) 70 | c.Writer.Header().Del("Content-Length") 71 | } 72 | -------------------------------------------------------------------------------- /internal/service/emby/items.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "math/rand" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 15 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 18 | "github.com/AmbitiousJun/go-emby2alist/internal/web/cache" 19 | 20 | "github.com/gin-gonic/gin" 21 | ) 22 | 23 | const ( 24 | 25 | // ItemsCacheSpace 专门存放 items 信息的缓存空间 26 | ItemsCacheSpace = "UserItems" 27 | 28 | // ResortMinNum 至少请求多少个 item 时才会走重排序逻辑 29 | ResortMinNum = 300 30 | ) 31 | 32 | // ResortRandomItems 对随机的 items 列表进行重排序 33 | func ResortRandomItems(c *gin.Context) { 34 | // 如果没有开启配置, 代理原请求并返回 35 | if !config.C.Emby.ResortRandomItems { 36 | ProxyOrigin(c) 37 | return 38 | } 39 | 40 | // 如果请求的个数较少, 认为不是随机播放列表, 代理原请求并返回 41 | limit, err := strconv.Atoi(c.Query("Limit")) 42 | if err == nil && limit < ResortMinNum { 43 | ProxyOrigin(c) 44 | return 45 | } 46 | 47 | // 优先从缓存空间中获取列表 48 | var code int 49 | var header http.Header 50 | var bodyBytes []byte 51 | spaceCache, ok := cache.GetSpaceCache(ItemsCacheSpace, calcRandomItemsCacheKey(c)) 52 | if ok { 53 | bodyBytes = spaceCache.BodyBytes() 54 | code = spaceCache.Code() 55 | header = spaceCache.Headers() 56 | log.Println(colors.ToBlue("使用缓存空间中的 random items 列表")) 57 | } else { 58 | // 请求原始列表 59 | u := strings.ReplaceAll(https.ClientRequestUrl(c), "/Items", "/Items/with_limit") 60 | resp, err := https.Request(http.MethodGet, u, c.Request.Header, c.Request.Body) 61 | if checkErr(c, err) { 62 | return 63 | } 64 | 65 | if resp.StatusCode != http.StatusOK { 66 | checkErr(c, fmt.Errorf("错误的响应码: %d", resp.StatusCode)) 67 | return 68 | } 69 | 70 | // 转换 json 响应 71 | bodyBytes, err = io.ReadAll(resp.Body) 72 | resp.Body.Close() 73 | if checkErr(c, err) { 74 | return 75 | } 76 | code = resp.StatusCode 77 | header = resp.Header 78 | } 79 | 80 | // writeRespErr 响应客户端, 根据 err 自动判断 81 | // 如果 err 不为空, 直接使用原始 bodyBytes 82 | writeRespErr := func(err error, respBody []byte) { 83 | if err != nil { 84 | log.Printf(colors.ToRed("随机排序接口非预期响应, err: %v, 返回原始响应"), err) 85 | respBody = bodyBytes 86 | } 87 | c.Status(code) 88 | header.Del("Content-Length") 89 | https.CloneHeader(c, header) 90 | c.Writer.Write(respBody) 91 | c.Writer.Flush() 92 | } 93 | 94 | // 对 item 内部结构不关心, 故使用原始的 json 序列化提高处理速度 95 | var resMain map[string]json.RawMessage 96 | if err := json.Unmarshal(bodyBytes, &resMain); err != nil { 97 | writeRespErr(err, nil) 98 | return 99 | } 100 | var resItems []json.RawMessage 101 | if err := json.Unmarshal(resMain["Items"], &resItems); err != nil { 102 | writeRespErr(err, nil) 103 | return 104 | } 105 | itemLen := len(resItems) 106 | if itemLen == 0 { 107 | writeRespErr(nil, bodyBytes) 108 | return 109 | } 110 | 111 | rand.Shuffle(itemLen, func(i, j int) { 112 | resItems[i], resItems[j] = resItems[j], resItems[i] 113 | }) 114 | 115 | newItemsBytes, _ := json.Marshal(resItems) 116 | resMain["Items"] = newItemsBytes 117 | newBodyBytes, _ := json.Marshal(resMain) 118 | writeRespErr(nil, newBodyBytes) 119 | } 120 | 121 | // RandomItemsWithLimit 代理原始的随机列表接口 122 | func RandomItemsWithLimit(c *gin.Context) { 123 | u := c.Request.URL 124 | u.Path = strings.TrimSuffix(u.Path, "/with_limit") 125 | q := u.Query() 126 | q.Set("Limit", "500") 127 | q.Del("SortOrder") 128 | u.RawQuery = q.Encode() 129 | embyHost := config.C.Emby.Host 130 | c.Request.Header.Del("Accept-Encoding") 131 | resp, err := https.Request(c.Request.Method, embyHost+u.String(), c.Request.Header, c.Request.Body) 132 | if checkErr(c, err) { 133 | return 134 | } 135 | defer resp.Body.Close() 136 | if resp.StatusCode != http.StatusOK { 137 | checkErr(c, fmt.Errorf("错误的响应码: %v", resp.StatusCode)) 138 | return 139 | } 140 | 141 | c.Status(resp.StatusCode) 142 | https.CloneHeader(c, resp.Header) 143 | c.Header(cache.HeaderKeyExpired, cache.Duration(time.Hour*3)) 144 | c.Header(cache.HeaderKeySpace, ItemsCacheSpace) 145 | c.Header(cache.HeaderKeySpaceKey, calcRandomItemsCacheKey(c)) 146 | io.Copy(c.Writer, resp.Body) 147 | } 148 | 149 | // calcRandomItemsCacheKey 计算 random items 在缓存空间中的 key 值 150 | func calcRandomItemsCacheKey(c *gin.Context) string { 151 | return c.Query("IncludeItemTypes") + 152 | c.Query("Recursive") + 153 | c.Query("Fields") + 154 | c.Query("EnableImageTypes") + 155 | c.Query("ImageTypeLimit") + 156 | c.Query("IsFavorite") + 157 | c.Query("IsFolder") + 158 | c.Query("ProjectToMedia") + 159 | c.Query("ParentId") 160 | } 161 | 162 | // ProxyAddItemsPreviewInfo 代理 Items 接口, 并附带上转码版本信息 163 | func ProxyAddItemsPreviewInfo(c *gin.Context) { 164 | // 检查用户是否启用了转码版本获取 165 | if !config.C.VideoPreview.Enable { 166 | ProxyOrigin(c) 167 | return 168 | } 169 | 170 | // 代理请求 171 | embyHost := config.C.Emby.Host 172 | c.Request.Header.Del("Accept-Encoding") 173 | resp, err := https.Request(c.Request.Method, embyHost+c.Request.URL.String(), c.Request.Header, c.Request.Body) 174 | if checkErr(c, err) { 175 | return 176 | } 177 | defer resp.Body.Close() 178 | 179 | // 检查响应, 读取为 JSON 180 | if resp.StatusCode != http.StatusOK { 181 | checkErr(c, fmt.Errorf("emby 远程返回了错误的响应码: %d", resp.StatusCode)) 182 | return 183 | } 184 | resJson, err := jsons.Read(resp.Body) 185 | if checkErr(c, err) { 186 | return 187 | } 188 | 189 | // 预响应请求 190 | defer func() { 191 | https.CloneHeader(c, resp.Header) 192 | jsons.OkResp(c, resJson) 193 | }() 194 | 195 | // 获取 Items 数组 196 | itemsArr, ok := resJson.Attr("Items").Done() 197 | if !ok || itemsArr.Empty() || itemsArr.Type() != jsons.JsonTypeArr { 198 | return 199 | } 200 | 201 | // 遍历每个 Item, 修改 MediaSource 信息 202 | proresMediaStreams, _ := jsons.New(`[{"AspectRatio":"16:9","AttachmentSize":0,"AverageFrameRate":25,"BitDepth":8,"BitRate":4838626,"Codec":"prores","CodecTag":"hev1","DisplayTitle":"4K HEVC","ExtendedVideoSubType":"None","ExtendedVideoSubTypeDescription":"None","ExtendedVideoType":"None","Height":2160,"Index":0,"IsDefault":true,"IsExternal":false,"IsForced":false,"IsHearingImpaired":false,"IsInterlaced":false,"IsTextSubtitleStream":false,"Language":"und","Level":150,"PixelFormat":"yuv420p","Profile":"Main","Protocol":"File","RealFrameRate":25,"RefFrames":1,"SupportsExternalStream":false,"TimeBase":"1/90000","Type":"Video","VideoRange":"SDR","Width":3840},{"AttachmentSize":0,"BitRate":124573,"ChannelLayout":"stereo","Channels":2,"Codec":"aac","CodecTag":"mp4a","DisplayTitle":"AAC stereo (默认)","ExtendedVideoSubType":"None","ExtendedVideoSubTypeDescription":"None","ExtendedVideoType":"None","Index":1,"IsDefault":true,"IsExternal":false,"IsForced":false,"IsHearingImpaired":false,"IsInterlaced":false,"IsTextSubtitleStream":false,"Language":"und","Profile":"LC","Protocol":"File","SampleRate":44100,"SupportsExternalStream":false,"TimeBase":"1/44100","Type":"Audio"}]`) 203 | itemsArr.RangeArr(func(index int, item *jsons.Item) error { 204 | mediaSources, ok := item.Attr("MediaSources").Done() 205 | if !ok || mediaSources.Empty() { 206 | return nil 207 | } 208 | 209 | toAdd := make([]*jsons.Item, 0) 210 | mediaSources.RangeArr(func(_ int, ms *jsons.Item) error { 211 | originId, _ := ms.Attr("Id").String() 212 | originName := findMediaSourceName(ms) 213 | allTplIds := getAllPreviewTemplateIds() 214 | ms.Put("Name", jsons.NewByVal("(原画) "+originName)) 215 | 216 | for _, tplId := range allTplIds { 217 | copyMs := jsons.NewByVal(ms.Struct()) 218 | copyMs.Put("Name", jsons.NewByVal(fmt.Sprintf("(%s) %s", tplId, originName))) 219 | copyMs.Put("Id", jsons.NewByVal(fmt.Sprintf("%s%s%s", originId, MediaSourceIdSegment, tplId))) 220 | copyMs.Put("MediaStreams", proresMediaStreams) 221 | toAdd = append(toAdd, copyMs) 222 | } 223 | return nil 224 | }) 225 | 226 | mediaSources.Append(toAdd...) 227 | return nil 228 | }) 229 | } 230 | -------------------------------------------------------------------------------- /internal/service/emby/media.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "regexp" 12 | "strings" 13 | "sync" 14 | 15 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/service/alist" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/service/path" 18 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 19 | "github.com/AmbitiousJun/go-emby2alist/internal/util/randoms" 20 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 21 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 22 | 23 | "github.com/gin-gonic/gin" 24 | ) 25 | 26 | // MediaSourceIdSegment 自定义 MediaSourceId 的分隔符 27 | const MediaSourceIdSegment = "[[_]]" 28 | 29 | // getEmbyFileLocalPath 获取 Emby 指定资源的 Path 参数 30 | // 31 | // 优先从缓存空间中获取 PlaybackInfo 数据 32 | // 33 | // uri 中必须有 query 参数 MediaSourceId, 34 | // 如果没有携带该参数, 可能会请求到多个资源, 默认返回第一个资源 35 | func getEmbyFileLocalPath(itemInfo ItemInfo) (string, error) { 36 | var header http.Header 37 | if itemInfo.ApiKeyType == Header { 38 | // 带上请求头的 api key 39 | header = http.Header{itemInfo.ApiKeyName: []string{itemInfo.ApiKey}} 40 | } 41 | 42 | res, _ := Fetch(itemInfo.PlaybackInfoUri, http.MethodPost, header, nil) 43 | if res.Code != http.StatusOK { 44 | return "", fmt.Errorf("请求 Emby 接口异常, error: %s", res.Msg) 45 | } 46 | body := res.Data 47 | 48 | mediaSources, ok := body.Attr("MediaSources").Done() 49 | if !ok { 50 | return "", fmt.Errorf("获取不到 MediaSources, 原始响应: %v", body) 51 | } 52 | 53 | var path string 54 | var defaultPath string 55 | 56 | reqId := itemInfo.MsInfo.RawId 57 | // 获取指定 MediaSourceId 的 Path 58 | mediaSources.RangeArr(func(_ int, value *jsons.Item) error { 59 | if strs.AnyEmpty(defaultPath) { 60 | // 默认选择第一个路径 61 | defaultPath, _ = value.Attr("Path").String() 62 | } 63 | if itemInfo.MsInfo.Empty { 64 | // 如果没有传递 MediaSourceId, 就使用默认的 Path 65 | return jsons.ErrBreakRange 66 | } 67 | 68 | curId := value.Attr("Id").Val().(string) 69 | if curId == reqId { 70 | path, _ = value.Attr("Path").String() 71 | return jsons.ErrBreakRange 72 | } 73 | return nil 74 | }) 75 | 76 | if strs.AllNotEmpty(path) { 77 | return path, nil 78 | } 79 | if strs.AllNotEmpty(defaultPath) { 80 | return defaultPath, nil 81 | } 82 | return "", fmt.Errorf("获取不到 Path 参数, 原始响应: %v", body) 83 | } 84 | 85 | // findVideoPreviewInfos 查找 source 的所有转码资源 86 | // 87 | // 传递 resChan 进行异步查询, 通过监听 resChan 获取查询结果 88 | func findVideoPreviewInfos(source *jsons.Item, originName, clientApiKey string, resChan chan []*jsons.Item) { 89 | if source == nil || source.Type() != jsons.JsonTypeObj { 90 | resChan <- nil 91 | return 92 | } 93 | 94 | // 转换 alist 绝对路径 95 | alistPathRes := path.Emby2Alist(source.Attr("Path").Val().(string)) 96 | var transcodingList, subtitleList *jsons.Item 97 | firstFetchSuccess := false 98 | if alistPathRes.Success { 99 | res := alist.FetchFsOther(alistPathRes.Path, nil) 100 | 101 | if res.Code == http.StatusOK { 102 | if list, ok := res.Data.Attr("video_preview_play_info").Attr("live_transcoding_task_list").Done(); ok { 103 | firstFetchSuccess = true 104 | transcodingList = list 105 | } 106 | if list, ok := res.Data.Attr("video_preview_play_info").Attr("live_transcoding_subtitle_task_list").Done(); ok { 107 | subtitleList = list 108 | } 109 | } 110 | 111 | if res.Code == http.StatusForbidden { 112 | resChan <- nil 113 | return 114 | } 115 | } 116 | 117 | // 首次请求失败, 遍历 alist 所有根目录, 重新请求 118 | if !firstFetchSuccess { 119 | paths, err := alistPathRes.Range() 120 | if err != nil { 121 | log.Printf("转换 alist 路径异常: %v", err) 122 | resChan <- nil 123 | return 124 | } 125 | 126 | for i := 0; i < len(paths); i++ { 127 | res := alist.FetchFsOther(paths[i], nil) 128 | if res.Code == http.StatusOK { 129 | if list, ok := res.Data.Attr("video_preview_play_info").Attr("live_transcoding_task_list").Done(); ok { 130 | transcodingList = list 131 | } 132 | if list, ok := res.Data.Attr("video_preview_play_info").Attr("live_transcoding_subtitle_task_list").Done(); ok { 133 | subtitleList = list 134 | } 135 | break 136 | } 137 | } 138 | } 139 | 140 | if transcodingList == nil || 141 | transcodingList.Empty() || 142 | transcodingList.Type() != jsons.JsonTypeArr { 143 | resChan <- nil 144 | return 145 | } 146 | 147 | res := make([]*jsons.Item, transcodingList.Len()) 148 | wg := sync.WaitGroup{} 149 | itemId, _ := source.Attr("ItemId").String() 150 | transcodingList.RangeArr(func(idx int, transcode *jsons.Item) error { 151 | wg.Add(1) 152 | go func() { 153 | defer wg.Done() 154 | templateId, _ := transcode.Attr("template_id").String() 155 | if config.C.VideoPreview.IsTemplateIgnore(templateId) { 156 | // 当前清晰度被忽略 157 | return 158 | } 159 | 160 | copySource := jsons.NewByVal(source.Struct()) 161 | templateWidth, _ := transcode.Attr("template_width").Int() 162 | templateHeight, _ := transcode.Attr("template_height").Int() 163 | format := fmt.Sprintf("%dx%d", templateWidth, templateHeight) 164 | copySource.Attr("Name").Set(fmt.Sprintf("(%s_%s) %s", templateId, format, originName)) 165 | 166 | // 重要!!!这里的 id 必须和原本的 id 不一样, 但又要确保能够正常反推出原本的 id 167 | newId := fmt.Sprintf( 168 | "%s%s%s%s%s%s%s", 169 | source.Attr("Id").Val(), MediaSourceIdSegment, 170 | templateId, MediaSourceIdSegment, 171 | format, MediaSourceIdSegment, 172 | alist.PathEncode(alistPathRes.Path), 173 | ) 174 | copySource.Attr("Id").Set(newId) 175 | 176 | // 设置转码代理播放链接 177 | tu, _ := url.Parse(strings.ReplaceAll(MasterM3U8UrlTemplate, "${itemId}", itemId)) 178 | q := tu.Query() 179 | q.Set("alist_path", alist.PathEncode(alistPathRes.Path)) 180 | q.Set("template_id", templateId) 181 | q.Set(QueryApiKeyName, clientApiKey) 182 | tu.RawQuery = q.Encode() 183 | 184 | // 标记转码资源使用转码容器 185 | copySource.Put("SupportsTranscoding", jsons.NewByVal(true)) 186 | copySource.Put("TranscodingContainer", jsons.NewByVal("ts")) 187 | copySource.Put("TranscodingSubProtocol", jsons.NewByVal("hls")) 188 | copySource.Put("TranscodingUrl", jsons.NewByVal(tu.String())) 189 | copySource.DelKey("DirectStreamUrl") 190 | copySource.Put("SupportsDirectPlay", jsons.NewByVal(false)) 191 | copySource.Put("SupportsDirectStream", jsons.NewByVal(false)) 192 | 193 | // 设置转码字幕 194 | addSubtitles2MediaStreams(copySource, subtitleList, alistPathRes.Path, templateId, clientApiKey) 195 | 196 | res[idx] = copySource 197 | }() 198 | return nil 199 | }) 200 | wg.Wait() 201 | 202 | // 移除 res 中的空值项 203 | for i := 0; i < len(res); { 204 | if res[i] != nil { 205 | i++ 206 | continue 207 | } 208 | res = append(res[:i], res[i+1:]...) 209 | } 210 | 211 | resChan <- res 212 | } 213 | 214 | // addSubtitles2MediaStreams 添加转码字幕到 PlaybackInfo 的 MediaStreams 项中 215 | // 216 | // subtitleList 是请求 alist 转码信息接口获取到的字幕列表 217 | func addSubtitles2MediaStreams(source, subtitleList *jsons.Item, alistPath, templateId, clientApiKey string) { 218 | // 1 json 参数类型校验 219 | if source == nil || subtitleList == nil || subtitleList.Empty() { 220 | return 221 | } 222 | mediaStreams, ok := source.Attr("MediaStreams").Done() 223 | if !ok || mediaStreams.Type() != jsons.JsonTypeArr { 224 | return 225 | } 226 | 227 | // 2 去除原始的字幕信息 228 | mediaStreams = mediaStreams.Filter(func(val *jsons.Item) bool { 229 | return val != nil && val.Attr("Type").Val() != "Subtitle" 230 | }) 231 | source.Put("MediaStreams", mediaStreams) 232 | 233 | // 3 生成 MediaStream 234 | itemId, _ := source.Attr("ItemId").String() 235 | curMediaStreamsSize := mediaStreams.Len() 236 | fakeId := randoms.RandomHex(32) 237 | subtitleList.RangeArr(func(index int, sub *jsons.Item) error { 238 | subStream, _ := jsons.New(`{"AttachmentSize":0,"Codec":"vtt","DeliveryMethod":"External","DeliveryUrl":"/Videos/6066/4ce9f37fe8567a3898e66517b92cf2af/Subtitles/14/0/Stream.vtt?api_key=964a56845f6a4c4a8ba42204ec6f775c","DisplayTitle":"(VTT)","ExtendedVideoSubType":"None","ExtendedVideoSubTypeDescription":"None","ExtendedVideoType":"None","Index":14,"IsDefault":false,"IsExternal":true,"IsExternalUrl":false,"IsForced":false,"IsHearingImpaired":false,"IsInterlaced":false,"IsTextSubtitleStream":true,"Protocol":"File","SupportsExternalStream":true,"Type":"Subtitle"}`) 239 | 240 | lang, _ := sub.Attr("language").String() 241 | subStream.Put("DisplayLanguage", jsons.NewByVal(lang)) 242 | subStream.Put("Language", jsons.NewByVal(lang)) 243 | 244 | subName := urls.ResolveResourceName(sub.Attr("url").Val().(string)) 245 | subStream.Put("DisplayTitle", jsons.NewByVal(alist.SubLangDisplayName(lang))) 246 | subStream.Put("Title", jsons.NewByVal(fmt.Sprintf("(%s) %s", lang, subName))) 247 | 248 | idx := curMediaStreamsSize + index 249 | subStream.Put("Index", jsons.NewByVal(idx)) 250 | 251 | u, _ := url.Parse(fmt.Sprintf("/Videos/%s/%s/Subtitles/%d/0/Stream.vtt", itemId, fakeId, idx)) 252 | q := u.Query() 253 | q.Set("alist_path", alist.PathEncode(alistPath)) 254 | q.Set("template_id", templateId) 255 | q.Set("sub_name", subName) 256 | q.Set(QueryApiKeyName, clientApiKey) 257 | u.RawQuery = q.Encode() 258 | subStream.Put("DeliveryUrl", jsons.NewByVal(u.String())) 259 | 260 | mediaStreams.Append(subStream) 261 | return nil 262 | }) 263 | } 264 | 265 | // findMediaSourceName 查找 MediaSource 中的视频名称, 如 '1080p HEVC' 266 | func findMediaSourceName(source *jsons.Item) string { 267 | if source == nil || source.Type() != jsons.JsonTypeObj { 268 | return "" 269 | } 270 | 271 | mediaStreams, ok := source.Attr("MediaStreams").Done() 272 | if !ok || mediaStreams.Type() != jsons.JsonTypeArr { 273 | return source.Attr("Name").Val().(string) 274 | } 275 | 276 | idx := mediaStreams.FindIdx(func(val *jsons.Item) bool { 277 | return val.Attr("Type").Val() == "Video" 278 | }) 279 | if idx == -1 { 280 | return source.Attr("Name").Val().(string) 281 | } 282 | return mediaStreams.Ti().Idx(idx).Attr("DisplayTitle").Val().(string) 283 | } 284 | 285 | // itemIdRegex 用于匹配出请求 uri 中的 itemId 286 | var itemIdRegex = regexp.MustCompile(`(?:/emby)?/.*/(\d+)(?:/|\?)?`) 287 | 288 | // resolveItemInfo 解析 emby 资源 item 信息 289 | func resolveItemInfo(c *gin.Context) (ItemInfo, error) { 290 | if c == nil { 291 | return ItemInfo{}, errors.New("参数 c 不能为空") 292 | } 293 | 294 | // 匹配 item id 295 | uri := c.Request.RequestURI 296 | matches := itemIdRegex.FindStringSubmatch(uri) 297 | if len(matches) < 2 { 298 | return ItemInfo{}, fmt.Errorf("itemId 匹配失败, uri: %s", uri) 299 | } 300 | itemInfo := ItemInfo{Id: matches[1]} 301 | 302 | // 获取客户端请求的 api_key 303 | itemInfo.ApiKeyType, itemInfo.ApiKeyName, itemInfo.ApiKey = getApiKey(c) 304 | 305 | // 解析请求的媒体信息 306 | msInfo, err := resolveMediaSourceId(getRequestMediaSourceId(c)) 307 | if err != nil { 308 | return ItemInfo{}, fmt.Errorf("解析 MediaSource 失败, uri: %s, err: %v", uri, err) 309 | } 310 | itemInfo.MsInfo = msInfo 311 | 312 | u, err := url.Parse(fmt.Sprintf("/Items/%s/PlaybackInfo", itemInfo.Id)) 313 | if err != nil { 314 | return ItemInfo{}, fmt.Errorf("构建 PlaybackInfo uri 失败, err: %v", err) 315 | } 316 | q := u.Query() 317 | // 默认只携带 query 形式的 api key 318 | if itemInfo.ApiKeyType == Query { 319 | q.Set(itemInfo.ApiKeyName, itemInfo.ApiKey) 320 | } 321 | q.Set("reqformat", "json") 322 | q.Set("IsPlayback", "false") 323 | q.Set("AutoOpenLiveStream", "false") 324 | if !msInfo.Empty { 325 | q.Set("MediaSourceId", msInfo.OriginId) 326 | } 327 | u.RawQuery = q.Encode() 328 | itemInfo.PlaybackInfoUri = u.String() 329 | 330 | return itemInfo, nil 331 | } 332 | 333 | // getRequestMediaSourceId 尝试从请求参数或请求体中获取 MediaSourceId 信息 334 | // 335 | // 优先返回请求参数中的值, 如果两者都获取不到, 就返回空字符串 336 | func getRequestMediaSourceId(c *gin.Context) string { 337 | if c == nil { 338 | return "" 339 | } 340 | 341 | // 1 从请求参数中获取 342 | q := c.Query("MediaSourceId") 343 | if strs.AllNotEmpty(q) { 344 | return q 345 | } 346 | 347 | // 2 从请求体中获取 348 | bodyBytes, err := io.ReadAll(c.Request.Body) 349 | if err != nil { 350 | return "" 351 | } 352 | c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 353 | reqJson, err := jsons.New(string(bodyBytes)) 354 | if err != nil { 355 | return "" 356 | } 357 | if msId, ok := reqJson.Attr("MediaSourceId").String(); ok { 358 | return msId 359 | } 360 | return "" 361 | } 362 | 363 | // resolveMediaSourceId 解析 MediaSourceId 364 | func resolveMediaSourceId(id string) (MsInfo, error) { 365 | res := MsInfo{Empty: true, RawId: id} 366 | 367 | if id == "" { 368 | return res, nil 369 | } 370 | res.Empty = false 371 | 372 | if len(id) <= 32 { 373 | res.OriginId = id 374 | return res, nil 375 | } 376 | 377 | segments := strings.Split(id, MediaSourceIdSegment) 378 | 379 | if len(segments) == 2 { 380 | res.Transcode = true 381 | res.OriginId = segments[0] 382 | res.TemplateId = segments[1] 383 | return res, nil 384 | } 385 | 386 | if len(segments) == 4 { 387 | res.Transcode = true 388 | res.OriginId = segments[0] 389 | res.TemplateId = segments[1] 390 | res.Format = segments[2] 391 | res.AlistPath = segments[3] 392 | res.SourceNamePrefix = fmt.Sprintf("%s_%s", res.TemplateId, res.Format) 393 | return res, nil 394 | } 395 | 396 | return MsInfo{}, errors.New("MediaSourceId 格式错误: " + id) 397 | } 398 | 399 | // getAllPreviewTemplateIds 获取所有转码格式 400 | // 401 | // 在配置文件中忽略的格式不会返回 402 | func getAllPreviewTemplateIds() []string { 403 | allIds := []string{"LD", "SD", "HD", "FHD", "QHD"} 404 | 405 | res := []string{} 406 | for _, id := range allIds { 407 | if config.C.VideoPreview.IsTemplateIgnore(id) { 408 | continue 409 | } 410 | res = append(res, id) 411 | } 412 | return res 413 | } 414 | -------------------------------------------------------------------------------- /internal/service/emby/media_test.go: -------------------------------------------------------------------------------- 1 | package emby_test 2 | 3 | import ( 4 | "log" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestMatchItemId(t *testing.T) { 11 | var itemIdRegex = regexp.MustCompile(`(?:/emby)?/[^/]+/(\d+)/`) 12 | str := "/emby/Items/2008/PlaybackInfo" 13 | res := itemIdRegex.FindStringSubmatch(str) 14 | log.Println(res[1]) 15 | str = strings.ReplaceAll(str, "/emby", "") 16 | res = itemIdRegex.FindStringSubmatch(str) 17 | log.Println(res[1]) 18 | } 19 | -------------------------------------------------------------------------------- /internal/service/emby/playbackinfo.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 18 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 19 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 20 | "github.com/AmbitiousJun/go-emby2alist/internal/web/cache" 21 | 22 | "github.com/gin-gonic/gin" 23 | ) 24 | 25 | const ( 26 | 27 | // PlaybackCacheSpace PlaybackInfo 的缓存空间 key 28 | PlaybackCacheSpace = "PlaybackInfo" 29 | 30 | // MasterM3U8UrlTemplate 转码 m3u8 地址模板 31 | MasterM3U8UrlTemplate = `/videos/${itemId}/master.m3u8?DeviceId=a690fc29-1f3e-423b-ba23-f03049361a3b\u0026MediaSourceId=83ed6e4e3d820864a3d07d2ef9efab2e\u0026PlaySessionId=9f01e60a22c74ad0847319175912663b\u0026api_key=f53f3bf34c0543ed81415b86576058f2\u0026LiveStreamId=06044cf0e6f93cdae5f285c9ecfaaeb4_01413a525b3a9622ce6fdf19f7dde354_83ed6e4e3d820864a3d07d2ef9efab2e\u0026VideoCodec=h264,h265,hevc,av1\u0026AudioCodec=mp3,aac\u0026VideoBitrate=6808000\u0026AudioBitrate=192000\u0026AudioStreamIndex=1\u0026TranscodingMaxAudioChannels=2\u0026SegmentContainer=ts\u0026MinSegments=1\u0026BreakOnNonKeyFrames=True\u0026SubtitleStreamIndexes=-1\u0026ManifestSubtitles=vtt\u0026h264-profile=high,main,baseline,constrainedbaseline,high10\u0026h264-level=62\u0026hevc-codectag=hvc1,hev1,hevc,hdmv` 32 | 33 | // PlaybackCommonPayload 请求 PlaybackInfo 的通用请求体 34 | PlaybackCommonPayload = `{"DeviceProfile":{"MaxStaticBitrate":140000000,"MaxStreamingBitrate":140000000,"MusicStreamingTranscodingBitrate":192000,"DirectPlayProfiles":[{"Container":"mp4,m4v","Type":"Video","VideoCodec":"h264,h265,hevc,av1,vp8,vp9","AudioCodec":"mp3,aac,opus,flac,vorbis"},{"Container":"mkv","Type":"Video","VideoCodec":"h264,h265,hevc,av1,vp8,vp9","AudioCodec":"mp3,aac,opus,flac,vorbis"},{"Container":"flv","Type":"Video","VideoCodec":"h264","AudioCodec":"aac,mp3"},{"Container":"3gp","Type":"Video","VideoCodec":"","AudioCodec":"mp3,aac,opus,flac,vorbis"},{"Container":"mov","Type":"Video","VideoCodec":"h264","AudioCodec":"mp3,aac,opus,flac,vorbis"},{"Container":"opus","Type":"Audio"},{"Container":"mp3","Type":"Audio","AudioCodec":"mp3"},{"Container":"mp2,mp3","Type":"Audio","AudioCodec":"mp2"},{"Container":"m4a","AudioCodec":"aac","Type":"Audio"},{"Container":"mp4","AudioCodec":"aac","Type":"Audio"},{"Container":"flac","Type":"Audio"},{"Container":"webma,webm","Type":"Audio"},{"Container":"wav","Type":"Audio","AudioCodec":"PCM_S16LE,PCM_S24LE"},{"Container":"ogg","Type":"Audio"},{"Container":"webm","Type":"Video","AudioCodec":"vorbis,opus","VideoCodec":"av1,VP8,VP9"}],"TranscodingProfiles":[{"Container":"aac","Type":"Audio","AudioCodec":"aac","Context":"Streaming","Protocol":"hls","MaxAudioChannels":"2","MinSegments":"1","BreakOnNonKeyFrames":true},{"Container":"aac","Type":"Audio","AudioCodec":"aac","Context":"Streaming","Protocol":"http","MaxAudioChannels":"2"},{"Container":"mp3","Type":"Audio","AudioCodec":"mp3","Context":"Streaming","Protocol":"http","MaxAudioChannels":"2"},{"Container":"opus","Type":"Audio","AudioCodec":"opus","Context":"Streaming","Protocol":"http","MaxAudioChannels":"2"},{"Container":"wav","Type":"Audio","AudioCodec":"wav","Context":"Streaming","Protocol":"http","MaxAudioChannels":"2"},{"Container":"opus","Type":"Audio","AudioCodec":"opus","Context":"Static","Protocol":"http","MaxAudioChannels":"2"},{"Container":"mp3","Type":"Audio","AudioCodec":"mp3","Context":"Static","Protocol":"http","MaxAudioChannels":"2"},{"Container":"aac","Type":"Audio","AudioCodec":"aac","Context":"Static","Protocol":"http","MaxAudioChannels":"2"},{"Container":"wav","Type":"Audio","AudioCodec":"wav","Context":"Static","Protocol":"http","MaxAudioChannels":"2"},{"Container":"mkv","Type":"Video","AudioCodec":"mp3,aac,opus,flac,vorbis","VideoCodec":"h264,h265,hevc,av1,vp8,vp9","Context":"Static","MaxAudioChannels":"2","CopyTimestamps":true},{"Container":"ts","Type":"Video","AudioCodec":"mp3,aac","VideoCodec":"h264,h265,hevc,av1","Context":"Streaming","Protocol":"hls","MaxAudioChannels":"2","MinSegments":"1","BreakOnNonKeyFrames":true,"ManifestSubtitles":"vtt"},{"Container":"webm","Type":"Video","AudioCodec":"vorbis","VideoCodec":"vpx","Context":"Streaming","Protocol":"http","MaxAudioChannels":"2"},{"Container":"mp4","Type":"Video","AudioCodec":"mp3,aac,opus,flac,vorbis","VideoCodec":"h264","Context":"Static","Protocol":"http"}],"ContainerProfiles":[],"CodecProfiles":[{"Type":"VideoAudio","Codec":"aac","Conditions":[{"Condition":"Equals","Property":"IsSecondaryAudio","Value":"false","IsRequired":"false"}]},{"Type":"VideoAudio","Conditions":[{"Condition":"Equals","Property":"IsSecondaryAudio","Value":"false","IsRequired":"false"}]},{"Type":"Video","Codec":"h264","Conditions":[{"Condition":"EqualsAny","Property":"VideoProfile","Value":"high|main|baseline|constrained baseline|high 10","IsRequired":false},{"Condition":"LessThanEqual","Property":"VideoLevel","Value":"62","IsRequired":false}]},{"Type":"Video","Codec":"hevc","Conditions":[{"Condition":"EqualsAny","Property":"VideoCodecTag","Value":"hvc1|hev1|hevc|hdmv","IsRequired":false}]}],"SubtitleProfiles":[{"Format":"vtt","Method":"Hls"},{"Format":"eia_608","Method":"VideoSideData","Protocol":"hls"},{"Format":"eia_708","Method":"VideoSideData","Protocol":"hls"},{"Format":"vtt","Method":"External"},{"Format":"ass","Method":"External"},{"Format":"ssa","Method":"External"}],"ResponseProfiles":[{"Type":"Video","Container":"m4v","MimeType":"video/mp4"}]}}` 35 | ) 36 | 37 | var ( 38 | 39 | // ValidCacheItemsTypeRegex 校验 Items 的 Type 参数, 通过正则才覆盖 PlaybackInfo 缓存 40 | ValidCacheItemsTypeRegex = regexp.MustCompile(`(?i)(movie|episode)`) 41 | 42 | // UnvalidCacheItemsUARegex 特定客户端的 Items 请求, 不覆盖 PlaybackInfo 缓存 43 | UnvalidCacheItemsUARegex = regexp.MustCompile(`(?i)(infuse)`) 44 | ) 45 | 46 | // TransferPlaybackInfo 代理 PlaybackInfo 接口, 防止客户端转码 47 | func TransferPlaybackInfo(c *gin.Context) { 48 | // 1 解析资源信息 49 | itemInfo, err := resolveItemInfo(c) 50 | log.Printf(colors.ToBlue("ItemInfo 解析结果: %s"), jsons.NewByVal(itemInfo)) 51 | if checkErr(c, err) { 52 | return 53 | } 54 | 55 | // 如果是远程资源, 直接代理到源服务器 56 | if handleRemotePlayback(c, itemInfo) { 57 | c.Header(cache.HeaderKeyExpired, "-1") 58 | return 59 | } 60 | 61 | // 如果是指定 MediaSourceId 的 PlaybackInfo 信息, 就从缓存空间中获取 62 | msInfo := itemInfo.MsInfo 63 | if useCacheSpacePlaybackInfo(c, itemInfo) { 64 | c.Header(cache.HeaderKeyExpired, "-1") 65 | return 66 | } 67 | 68 | // 2 请求 emby 源服务器的 PlaybackInfo 信息 69 | c.Request.Header.Del("Accept-Encoding") 70 | originRequestBody := c.Request.Body 71 | c.Request.Body = io.NopCloser(bytes.NewBufferString(PlaybackCommonPayload)) 72 | res, respHeader := RawFetch(itemInfo.PlaybackInfoUri, c.Request.Method, c.Request.Header, c.Request.Body) 73 | if res.Code != http.StatusOK { 74 | checkErr(c, errors.New(res.Msg)) 75 | return 76 | } 77 | 78 | // 3 处理 JSON 响应 79 | resJson := res.Data 80 | mediaSources, ok := resJson.Attr("MediaSources").Done() 81 | if !ok || mediaSources.Type() != jsons.JsonTypeArr { 82 | checkErr(c, errors.New("获取不到 MediaSources 属性")) 83 | } 84 | 85 | if mediaSources.Empty() { 86 | log.Println(colors.ToYellow("没有找到可播放的资源")) 87 | jsons.OkResp(c, resJson) 88 | return 89 | } 90 | 91 | log.Printf(colors.ToBlue("获取到的 MediaSources 个数: %d"), mediaSources.Len()) 92 | var haveReturned = errors.New("have returned") 93 | resChans := make([]chan []*jsons.Item, 0) 94 | err = mediaSources.RangeArr(func(_ int, source *jsons.Item) error { 95 | // 如果客户端请求携带了 MediaSourceId 参数 96 | // 在返回数据时, 需要重新设置回原始的 Id 97 | if !msInfo.Empty { 98 | source.Attr("Id").Set(msInfo.RawId) 99 | } 100 | 101 | // 默认无限流为电视直播, 代理到源服务器 102 | iis, _ := source.Attr("IsInfiniteStream").Bool() 103 | if iis { 104 | c.Request.Body = originRequestBody 105 | ProxyOrigin(c) 106 | return haveReturned 107 | } 108 | 109 | // 如果是本地媒体, 不处理 110 | embyPath, _ := source.Attr("Path").String() 111 | if strings.HasPrefix(embyPath, config.C.Emby.LocalMediaRoot) { 112 | return nil 113 | } 114 | 115 | // 转换直链链接 116 | source.Put("SupportsDirectPlay", jsons.NewByVal(true)) 117 | source.Put("SupportsDirectStream", jsons.NewByVal(true)) 118 | newUrl := fmt.Sprintf( 119 | "/videos/%s/stream?MediaSourceId=%s&%s=%s&Static=true", 120 | itemInfo.Id, source.Attr("Id").Val(), itemInfo.ApiKeyName, itemInfo.ApiKey, 121 | ) 122 | source.Put("DirectStreamUrl", jsons.NewByVal(newUrl)) 123 | log.Printf(colors.ToBlue("设置直链播放链接为: %s"), newUrl) 124 | 125 | // 简化资源名称 126 | name := findMediaSourceName(source) 127 | if name != "" { 128 | source.Put("Name", jsons.NewByVal(name)) 129 | } 130 | name = source.Attr("Name").Val().(string) 131 | source.Attr("Name").Set(fmt.Sprintf("(原画) %s", name)) 132 | 133 | source.Put("SupportsTranscoding", jsons.NewByVal(false)) 134 | source.DelKey("TranscodingUrl") 135 | source.DelKey("TranscodingSubProtocol") 136 | source.DelKey("TranscodingContainer") 137 | log.Println(colors.ToBlue("转码配置被移除")) 138 | 139 | // 如果是远程资源, 不获取转码地址 140 | ir, _ := source.Attr("IsRemote").Bool() 141 | if ir { 142 | return nil 143 | } 144 | 145 | // 添加转码 MediaSource 获取 146 | cfg := config.C.VideoPreview 147 | if !msInfo.Empty || !cfg.Enable || !cfg.ContainerValid(source.Attr("Container").Val().(string)) { 148 | return nil 149 | } 150 | resChan := make(chan []*jsons.Item, 1) 151 | go findVideoPreviewInfos(source, name, itemInfo.ApiKey, resChan) 152 | resChans = append(resChans, resChan) 153 | return nil 154 | }) 155 | 156 | if err == haveReturned { 157 | return 158 | } 159 | 160 | defer func() { 161 | // 缓存 12h 162 | c.Header(cache.HeaderKeyExpired, cache.Duration(time.Hour*12)) 163 | // 将请求结果缓存到指定缓存空间下 164 | c.Header(cache.HeaderKeySpace, PlaybackCacheSpace) 165 | c.Header(cache.HeaderKeySpaceKey, calcPlaybackInfoSpaceCacheKey(itemInfo)) 166 | }() 167 | 168 | // 收集异步请求的转码资源信息 169 | for _, resChan := range resChans { 170 | previewInfos := <-resChan 171 | if len(previewInfos) > 0 { 172 | log.Printf(colors.ToGreen("找到 %d 个转码资源信息"), len(previewInfos)) 173 | mediaSources.Append(previewInfos...) 174 | } 175 | } 176 | 177 | https.CloneHeader(c, respHeader) 178 | jsons.OkResp(c, resJson) 179 | } 180 | 181 | // handleRemotePlayback 判断如果请求的 PlaybackInfo 信息是远程地址, 直接返回结果 182 | func handleRemotePlayback(c *gin.Context, itemInfo ItemInfo) bool { 183 | // 请求必须携带 MediaSourceId 184 | if itemInfo.MsInfo.Empty { 185 | return false 186 | } 187 | 188 | c.Request.Header.Del("Accept-Encoding") 189 | originRequestBody := c.Request.Body 190 | c.Request.Body = io.NopCloser(bytes.NewBufferString(PlaybackCommonPayload)) 191 | res, _ := RawFetch(itemInfo.PlaybackInfoUri, c.Request.Method, c.Request.Header, c.Request.Body) 192 | if res.Code != http.StatusOK { 193 | return false 194 | } 195 | 196 | mediaSources, ok := res.Data.Attr("MediaSources").Done() 197 | if !ok || mediaSources.Len() != 1 { 198 | return false 199 | } 200 | 201 | ms, _ := mediaSources.Idx(0).Done() 202 | iis, _ := ms.Attr("IsInfiniteStream").Bool() 203 | if iis { 204 | // 默认无限流为电视直播, 直接代理到源服务器 205 | c.Request.Body = originRequestBody 206 | ProxyOrigin(c) 207 | return true 208 | } 209 | 210 | return false 211 | } 212 | 213 | // useCacheSpacePlaybackInfo 请求缓存空间的 PlaybackInfo 信息, 前提是开启了缓存功能 214 | // 215 | // ① 请求携带 MediaSourceId: 216 | // 217 | // 从缓存空间的全量缓存中匹配 MediaSourceId, 没有全量缓存或者匹配失败直接报 500 错误 218 | // 219 | // ② 请求不携带 MediaSourceId: 220 | // 221 | // 先判断缓存空间是否有缓存, 没有缓存返回 false, 由主函数请求全量信息并缓存 222 | // 有缓存则直接返回缓存中的全量信息 223 | func useCacheSpacePlaybackInfo(c *gin.Context, itemInfo ItemInfo) bool { 224 | if c == nil { 225 | return false 226 | } 227 | reqId := itemInfo.MsInfo.RawId 228 | 229 | if !config.C.Cache.Enable { 230 | // 未开启缓存功能 231 | return false 232 | } 233 | 234 | // updateCache 刷新缓存空间的缓存 235 | // 236 | // 1 将 targetIdx 的 MediaSource 移至最前 237 | // 2 更新所有与 target 一致 ItemId 的 DefaultAudioStreamIndex 和 DefaultSubtitleStreamIndex 238 | updateCache := func(spaceCache cache.RespCache, jsonBody *jsons.Item, targetIdx int) { 239 | // 获取所有的 MediaSources 240 | mediaSources, ok := jsonBody.Attr("MediaSources").Done() 241 | if !ok { 242 | return 243 | } 244 | 245 | // 获取目标 MediaSource 246 | targetMs, ok := mediaSources.Idx(targetIdx).Done() 247 | if !ok { 248 | return 249 | } 250 | 251 | // 更新 MediaSource 252 | newAdoIdx, err := strconv.Atoi(c.Query("AudioStreamIndex")) 253 | var newAdoVal *jsons.Item 254 | if err == nil { 255 | newAdoVal = jsons.NewByVal(newAdoIdx) 256 | targetMs.Put("DefaultAudioStreamIndex", newAdoVal) 257 | } 258 | var newSubVal *jsons.Item 259 | newSubIdx, err := strconv.Atoi(c.Query("SubtitleStreamIndex")) 260 | if err == nil { 261 | newSubVal = jsons.NewByVal(newSubIdx) 262 | targetMs.Put("DefaultSubtitleStreamIndex", newSubVal) 263 | } 264 | 265 | // 准备一个新的 MediaSources 数组 266 | newMediaSources := jsons.NewEmptyArr() 267 | newMediaSources.Append(targetMs) 268 | targetItemId, _ := targetMs.Attr("ItemId").String() 269 | mediaSources.RangeArr(func(index int, value *jsons.Item) error { 270 | if index == targetIdx { 271 | return nil 272 | } 273 | curItemId, _ := value.Attr("ItemId").String() 274 | if curItemId == targetItemId { 275 | if newAdoVal != nil { 276 | value.Put("DefaultAudioStreamIndex", newAdoVal) 277 | } 278 | if newSubVal != nil { 279 | value.Put("DefaultSubtitleStreamIndex", newSubVal) 280 | } 281 | } 282 | newMediaSources.Append(value) 283 | return nil 284 | }) 285 | jsonBody.Put("MediaSources", newMediaSources) 286 | 287 | // 更新缓存 288 | newBody := []byte(jsonBody.String()) 289 | newHeader := spaceCache.Headers() 290 | newHeader.Set("Content-Length", strconv.Itoa(len(newBody))) 291 | spaceCache.Update(0, newBody, newHeader) 292 | log.Printf(colors.ToPurple("刷新缓存空间 PlaybackInfo 信息, space: %s, spaceKey: %s"), spaceCache.Space(), spaceCache.SpaceKey()) 293 | } 294 | 295 | // findMediaSourceAndReturn 从全量 PlaybackInfo 信息中查询指定 MediaSourceId 信息 296 | // 处理成功返回 true 297 | findMediaSourceAndReturn := func(spaceCache cache.RespCache) bool { 298 | jsonBody, err := spaceCache.JsonBody() 299 | if err != nil { 300 | log.Printf(colors.ToRed("解析缓存响应体失败: %v"), err) 301 | return false 302 | } 303 | 304 | mediaSources, ok := jsonBody.Attr("MediaSources").Done() 305 | if !ok || mediaSources.Type() != jsons.JsonTypeArr || mediaSources.Empty() { 306 | return false 307 | } 308 | newMediaSources := jsons.NewEmptyArr() 309 | mediaSources.RangeArr(func(index int, value *jsons.Item) error { 310 | cacheId := value.Attr("Id").Val().(string) 311 | if err == nil && cacheId == reqId { 312 | newMediaSources.Append(value) 313 | updateCache(spaceCache, jsonBody, index) 314 | return jsons.ErrBreakRange 315 | } 316 | return nil 317 | }) 318 | if newMediaSources.Empty() { 319 | return false 320 | } 321 | 322 | jsonBody.Put("MediaSources", newMediaSources) 323 | respHeader := spaceCache.Headers() 324 | https.CloneHeader(c, respHeader) 325 | jsons.OkResp(c, jsonBody) 326 | return true 327 | } 328 | 329 | // 1 查询缓存空间 330 | spaceCache, ok := getPlaybackInfoByCacheSpace(itemInfo) 331 | if ok { 332 | // 未传递 MediaSourceId, 返回整个缓存数据 333 | if itemInfo.MsInfo.Empty { 334 | log.Printf(colors.ToBlue("复用缓存空间中的 PlaybackInfo 信息, itemId: %s"), itemInfo.Id) 335 | c.Status(spaceCache.Code()) 336 | https.CloneHeader(c, spaceCache.Headers()) 337 | // 避免缓存的请求头中出现脏数据 338 | c.Header("Access-Control-Allow-Origin", "*") 339 | c.Writer.Write(spaceCache.BodyBytes()) 340 | c.Writer.Flush() 341 | return true 342 | } 343 | // 尝试从缓存中匹配指定的 MediaSourceId 信息 344 | if findMediaSourceAndReturn(spaceCache) { 345 | return true 346 | } 347 | } 348 | 349 | // 如果是全量查询, 从缓存中拿不到数据, 就触发手动请求全量 350 | if itemInfo.MsInfo.Empty { 351 | return false 352 | } 353 | 354 | // 如果是单个查询, 则手动请求一次全量 355 | if _, err := fetchFullPlaybackInfo(c, itemInfo); err != nil { 356 | log.Printf(colors.ToRed("更新缓存空间 PlaybackInfo 信息异常: %v"), err) 357 | c.String(http.StatusInternalServerError, "查无缓存, 请稍后尝试重新播放") 358 | return true 359 | } 360 | 361 | return useCacheSpacePlaybackInfo(c, itemInfo) 362 | } 363 | 364 | // LoadCacheItems 拦截并代理 items 接口 365 | // 366 | // 如果 PlaybackInfo 缓存空间有相应的缓存 367 | // 则将缓存中的 MediaSources 信息覆盖到响应中 368 | // 369 | // 防止转码资源信息丢失 370 | func LoadCacheItems(c *gin.Context) { 371 | // 代理请求 372 | res, ok := proxyAndSetRespHeader(c) 373 | if !ok { 374 | return 375 | } 376 | resJson := res.Data 377 | defer func() { 378 | jsons.OkResp(c, resJson) 379 | }() 380 | 381 | // 未开启转码资源获取功能 382 | if !config.C.VideoPreview.Enable { 383 | return 384 | } 385 | 386 | // 只处理特定类型的 Items 响应 387 | itemType, _ := resJson.Attr("Type").String() 388 | if !ValidCacheItemsTypeRegex.MatchString(itemType) { 389 | return 390 | } 391 | 392 | // 特定客户端不处理 393 | if UnvalidCacheItemsUARegex.MatchString(c.GetHeader("User-Agent")) { 394 | return 395 | } 396 | 397 | // 解析出 ItemId 398 | itemInfo, err := resolveItemInfo(c) 399 | if err != nil { 400 | return 401 | } 402 | log.Printf(colors.ToBlue("itemInfo 解析结果: %s"), jsons.NewByVal(itemInfo)) 403 | 404 | // coverMediaSources 解析 PlaybackInfo 中的 MediaSources 属性 405 | // 并覆盖到当前请求的响应中 406 | // 成功覆盖时, 返回 true 407 | coverMediaSources := func(info *jsons.Item) bool { 408 | cacheMs, ok := info.Attr("MediaSources").Done() 409 | if !ok || cacheMs.Type() != jsons.JsonTypeArr { 410 | return false 411 | } 412 | log.Printf(colors.ToBlue("使用 PlaybackInfo 的 MediaSources 覆盖 Items 接口响应, itemId: %s"), itemInfo.Id) 413 | resJson.Put("MediaSources", cacheMs) 414 | c.Writer.Header().Del("Content-Length") 415 | return true 416 | } 417 | 418 | // 获取附带转码信息的 PlaybackInfo 数据 419 | spaceCache, ok := getPlaybackInfoByCacheSpace(itemInfo) 420 | if ok { 421 | cacheBody, err := spaceCache.JsonBody() 422 | if err == nil && coverMediaSources(cacheBody) { 423 | return 424 | } 425 | } 426 | 427 | // 缓存空间中没有当前 Item 的 PlaybackInfo 数据, 手动请求 428 | bodyJson, err := fetchFullPlaybackInfo(c, itemInfo) 429 | if err != nil { 430 | log.Printf(colors.ToYellow("更新 Items 缓存异常: %v"), err) 431 | return 432 | } 433 | coverMediaSources(bodyJson) 434 | } 435 | 436 | // fetchFullPlaybackInfo 请求全量的 PlaybackInfo 信息 437 | func fetchFullPlaybackInfo(c *gin.Context, itemInfo ItemInfo) (*jsons.Item, error) { 438 | u, err := url.Parse(https.ClientRequestHost(c) + itemInfo.PlaybackInfoUri) 439 | if err != nil { 440 | return nil, fmt.Errorf("PlaybackInfo 地址异常: %v, uri: %s", err, itemInfo.PlaybackInfoUri) 441 | } 442 | q := u.Query() 443 | q.Del("MediaSourceId") 444 | u.RawQuery = q.Encode() 445 | 446 | reqBody := io.NopCloser(bytes.NewBufferString(PlaybackCommonPayload)) 447 | header := make(http.Header) 448 | header.Set("Content-Type", "text/plain") 449 | if itemInfo.ApiKeyType == Header { 450 | header.Set(itemInfo.ApiKeyName, itemInfo.ApiKey) 451 | } 452 | resp, err := https.Request(http.MethodPost, u.String(), header, reqBody) 453 | if err != nil { 454 | return nil, fmt.Errorf("获取全量 PlaybackInfo 失败: %v", err) 455 | } 456 | defer resp.Body.Close() 457 | if resp.StatusCode != http.StatusOK { 458 | return nil, fmt.Errorf("获取全量 PlaybackInfo 失败, code: %d", resp.StatusCode) 459 | } 460 | 461 | bodyJson, err := jsons.Read(resp.Body) 462 | if err != nil { 463 | return nil, fmt.Errorf("获取全量 PlaybackInfo 失败: %v", err) 464 | } 465 | return bodyJson, nil 466 | } 467 | 468 | // calcPlaybackInfoSpaceCacheKey 根据请求的 item 信息计算 PlaybackInfo 在缓存空间中的 key 469 | func calcPlaybackInfoSpaceCacheKey(itemInfo ItemInfo) string { 470 | return itemInfo.Id + "_" + itemInfo.ApiKey 471 | } 472 | 473 | // getPlaybackInfoByCacheSpace 从缓存空间中获取 PlaybackInfo 信息 474 | func getPlaybackInfoByCacheSpace(itemInfo ItemInfo) (cache.RespCache, bool) { 475 | spaceCache, ok := cache.GetSpaceCache(PlaybackCacheSpace, calcPlaybackInfoSpaceCacheKey(itemInfo)) 476 | if !ok { 477 | return nil, false 478 | } 479 | return spaceCache, true 480 | } 481 | -------------------------------------------------------------------------------- /internal/service/emby/playing.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "strconv" 10 | 11 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 12 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 13 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 14 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 15 | "github.com/AmbitiousJun/go-emby2alist/internal/util/randoms" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 17 | "github.com/gin-gonic/gin" 18 | ) 19 | 20 | // PlayingStoppedHelper 拦截停止播放接口, 然后手动请求一次 Progress 接口记录进度 21 | func PlayingStoppedHelper(c *gin.Context) { 22 | // 取出原始请求体信息 23 | bodyBytes, err := https.ExtractReqBody(c) 24 | if checkErr(c, err) { 25 | return 26 | } 27 | bodyJson, err := jsons.New(string(bodyBytes)) 28 | if checkErr(c, err) { 29 | return 30 | } 31 | 32 | // 代理原始 Stopped 接口 33 | ProxyOrigin(c) 34 | 35 | // 提取 api apiKey 36 | kType, kName, apiKey := getApiKey(c) 37 | 38 | // 至少播放 5 分钟才记录进度 39 | positionTicks, ok := bodyJson.Attr("PositionTicks").Int64() 40 | var minPos int64 = 5 * 60 * 10_000_000 41 | if !ok || positionTicks < minPos { 42 | return 43 | } 44 | 45 | // 发送辅助请求记录播放进度 46 | itemId, _ := bodyJson.Attr("ItemId").String() 47 | if itemIdNum, ok := bodyJson.Attr("ItemId").Int(); ok { 48 | itemId = strconv.Itoa(itemIdNum) 49 | } 50 | if strs.AnyEmpty(itemId) { 51 | return 52 | } 53 | body := jsons.NewEmptyObj() 54 | body.Put("ItemId", jsons.NewByVal(itemId)) 55 | body.Put("PlaySessionId", jsons.NewByVal(randoms.RandomHex(32))) 56 | body.Put("PositionTicks", jsons.NewByVal(bodyJson.Attr("PositionTicks").Val())) 57 | go sendPlayingProgress(kType, kName, apiKey, body) 58 | } 59 | 60 | // PlayingProgressHelper 拦截 Progress 请求, 如果进度报告为 0, 认为是无效请求 61 | func PlayingProgressHelper(c *gin.Context) { 62 | // 取出原始请求体信息 63 | bodyBytes, err := https.ExtractReqBody(c) 64 | if checkErr(c, err) { 65 | return 66 | } 67 | bodyJson, err := jsons.New(string(bodyBytes)) 68 | if checkErr(c, err) { 69 | return 70 | } 71 | 72 | if pt, ok := bodyJson.Attr("PositionTicks").Int64(); ok && pt <= 10_000_000 { 73 | c.Status(http.StatusNoContent) 74 | return 75 | } 76 | ProxyOrigin(c) 77 | } 78 | 79 | // sendPlayingProgress 发送辅助播放进度请求 80 | func sendPlayingProgress(kType ApiKeyType, kName, apiKey string, body *jsons.Item) { 81 | if body == nil { 82 | return 83 | } 84 | 85 | inner := func(remote string) error { 86 | header := make(http.Header) 87 | header.Set("Content-Type", "application/json") 88 | if kType == Query { 89 | remote += fmt.Sprintf("?%s=%s", kName, apiKey) 90 | } else { 91 | header.Set(kName, apiKey) 92 | } 93 | resp, err := https.Request(http.MethodPost, remote, header, io.NopCloser(bytes.NewBuffer([]byte(body.String())))) 94 | if err != nil { 95 | return err 96 | } 97 | defer resp.Body.Close() 98 | if resp.StatusCode != http.StatusNoContent { 99 | return fmt.Errorf("源服务器返回错误状态: %v", resp.Status) 100 | } 101 | return nil 102 | } 103 | 104 | log.Printf(colors.ToGray("开始发送辅助 Progress 进度记录, 内容: %v"), body) 105 | if err := inner(config.C.Emby.Host + "/emby/Sessions/Playing/Progress"); err != nil { 106 | log.Printf(colors.ToYellow("辅助发送 Progress 进度记录失败: %v"), err) 107 | return 108 | } 109 | if err := inner(config.C.Emby.Host + "/emby/Sessions/Playing/Stopped"); err != nil { 110 | log.Printf(colors.ToYellow("辅助发送 Progress 进度记录失败: %v"), err) 111 | return 112 | } 113 | log.Println(colors.ToGreen("辅助发送 Progress 进度记录成功")) 114 | } 115 | -------------------------------------------------------------------------------- /internal/service/emby/redirect.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "slices" 10 | "strings" 11 | "time" 12 | 13 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 14 | "github.com/AmbitiousJun/go-emby2alist/internal/service/alist" 15 | "github.com/AmbitiousJun/go-emby2alist/internal/service/path" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 18 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 19 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 20 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 21 | "github.com/AmbitiousJun/go-emby2alist/internal/web/cache" 22 | 23 | "github.com/gin-gonic/gin" 24 | ) 25 | 26 | // Redirect2Transcode 将 master 请求重定向到本地 ts 代理 27 | func Redirect2Transcode(c *gin.Context) { 28 | // 只有三个必要的参数都获取到时, 才跳转到本地 ts 代理 29 | templateId := c.Query("template_id") 30 | apiKey := c.Query(QueryApiKeyName) 31 | alistPath := c.Query("alist_path") 32 | if strs.AnyEmpty(templateId, apiKey, alistPath) { 33 | ProxyOrigin(c) 34 | return 35 | } 36 | log.Println(colors.ToBlue("检测到自定义的转码 m3u8 请求, 重定向到本地代理接口")) 37 | tu, _ := url.Parse("/videos/proxy_playlist") 38 | q := tu.Query() 39 | q.Set("alist_path", alistPath) 40 | q.Set(QueryApiKeyName, apiKey) 41 | q.Set("template_id", templateId) 42 | tu.RawQuery = q.Encode() 43 | c.Redirect(http.StatusTemporaryRedirect, tu.String()) 44 | } 45 | 46 | // Redirect2AlistLink 重定向资源到 alist 网盘直链 47 | func Redirect2AlistLink(c *gin.Context) { 48 | // 1 解析要请求的资源信息 49 | itemInfo, err := resolveItemInfo(c) 50 | if checkErr(c, err) { 51 | return 52 | } 53 | log.Printf(colors.ToBlue("解析到的 itemInfo: %v"), jsons.NewByVal(itemInfo)) 54 | 55 | // 2 如果请求的是转码资源, 重定向到本地的 m3u8 代理服务 56 | msInfo := itemInfo.MsInfo 57 | useTranscode := !msInfo.Empty && msInfo.Transcode 58 | if useTranscode && msInfo.AlistPath != "" { 59 | u, _ := url.Parse(strings.ReplaceAll(MasterM3U8UrlTemplate, "${itemId}", itemInfo.Id)) 60 | q := u.Query() 61 | q.Set("template_id", itemInfo.MsInfo.TemplateId) 62 | q.Set(QueryApiKeyName, itemInfo.ApiKey) 63 | q.Set("alist_path", itemInfo.MsInfo.AlistPath) 64 | u.RawQuery = q.Encode() 65 | log.Printf(colors.ToGreen("重定向 playlist: %s"), u.String()) 66 | c.Redirect(http.StatusTemporaryRedirect, u.String()) 67 | return 68 | } 69 | 70 | // 3 请求资源在 Emby 中的 Path 参数 71 | embyPath, err := getEmbyFileLocalPath(itemInfo) 72 | if checkErr(c, err) { 73 | return 74 | } 75 | 76 | // 4 如果是远程地址 (strm), 重定向处理 77 | if urls.IsRemote(embyPath) { 78 | finalPath := config.C.Emby.Strm.MapPath(embyPath) 79 | finalPath = getFinalRedirectLink(finalPath, c.Request.Header.Clone()) 80 | log.Printf(colors.ToGreen("重定向 strm: %s"), finalPath) 81 | c.Header(cache.HeaderKeyExpired, cache.Duration(time.Minute*10)) 82 | c.Redirect(http.StatusTemporaryRedirect, finalPath) 83 | return 84 | } 85 | 86 | // 5 如果是本地地址, 回源处理 87 | if strings.HasPrefix(embyPath, config.C.Emby.LocalMediaRoot) { 88 | log.Printf(colors.ToBlue("本地媒体: %s, 回源处理"), embyPath) 89 | ProxyOrigin(c) 90 | return 91 | } 92 | 93 | // 6 请求 alist 资源 94 | fi := alist.FetchInfo{ 95 | Header: c.Request.Header.Clone(), 96 | UseTranscode: useTranscode, 97 | Format: msInfo.TemplateId, 98 | } 99 | alistPathRes := path.Emby2Alist(embyPath) 100 | 101 | allErrors := strings.Builder{} 102 | // handleAlistResource 根据传递的 path 请求 alist 资源 103 | handleAlistResource := func(path string) bool { 104 | log.Printf(colors.ToBlue("尝试请求 Alist 资源: %s"), path) 105 | fi.Path = path 106 | res := alist.FetchResource(fi) 107 | 108 | if res.Code != http.StatusOK { 109 | allErrors.WriteString(fmt.Sprintf("请求 Alist 失败, code: %d, msg: %s, path: %s;", res.Code, res.Msg, path)) 110 | return false 111 | } 112 | 113 | // 处理直链 114 | if !fi.UseTranscode { 115 | log.Printf(colors.ToGreen("请求成功, 重定向到: %s"), res.Data.Url) 116 | c.Header(cache.HeaderKeyExpired, cache.Duration(time.Minute*10)) 117 | c.Redirect(http.StatusTemporaryRedirect, res.Data.Url) 118 | return true 119 | } 120 | 121 | // 代理转码 m3u 122 | u, _ := url.Parse(strings.ReplaceAll(https.ClientRequestHost(c)+MasterM3U8UrlTemplate, "${itemId}", itemInfo.Id)) 123 | q := u.Query() 124 | q.Set("template_id", itemInfo.MsInfo.TemplateId) 125 | q.Set(QueryApiKeyName, itemInfo.ApiKey) 126 | q.Set("alist_path", alist.PathEncode(path)) 127 | u.RawQuery = q.Encode() 128 | resp, err := https.Request(http.MethodGet, u.String(), nil, nil) 129 | if err != nil { 130 | allErrors.WriteString(fmt.Sprintf("代理转码 m3u 失败: %v;", err)) 131 | return false 132 | } 133 | defer resp.Body.Close() 134 | c.Status(resp.StatusCode) 135 | https.CloneHeader(c, resp.Header) 136 | io.Copy(c.Writer, resp.Body) 137 | return true 138 | } 139 | 140 | if alistPathRes.Success && handleAlistResource(alistPathRes.Path) { 141 | return 142 | } 143 | paths, err := alistPathRes.Range() 144 | if checkErr(c, err) { 145 | return 146 | } 147 | if slices.ContainsFunc(paths, func(path string) bool { 148 | return handleAlistResource(path) 149 | }) { 150 | return 151 | } 152 | 153 | checkErr(c, fmt.Errorf("获取直链失败: %s", allErrors.String())) 154 | } 155 | 156 | // checkErr 检查 err 是否为空 157 | // 不为空则根据错误处理策略返回响应 158 | // 159 | // 返回 true 表示请求已经被处理 160 | func checkErr(c *gin.Context, err error) bool { 161 | if err == nil || c == nil { 162 | return false 163 | } 164 | 165 | // 异常接口, 不缓存 166 | c.Header(cache.HeaderKeyExpired, "-1") 167 | 168 | // 采用拒绝策略, 直接返回错误 169 | if config.C.Emby.ProxyErrorStrategy == config.PeStrategyReject { 170 | log.Printf(colors.ToRed("代理接口失败: %v"), err) 171 | c.String(http.StatusInternalServerError, "代理接口失败, 请检查日志") 172 | return true 173 | } 174 | 175 | log.Printf(colors.ToRed("代理接口失败: %v, 回源处理"), err) 176 | ProxyOrigin(c) 177 | return true 178 | } 179 | 180 | // getFinalRedirectLink 尝试对带有重定向的原始链接进行内部请求, 返回最终链接 181 | // 182 | // 请求中途出现任何失败都会返回原始链接 183 | func getFinalRedirectLink(originLink string, header http.Header) string { 184 | finalLink, resp, err := https.RequestRedirect(http.MethodGet, originLink, header, nil, true) 185 | if err != nil { 186 | log.Printf(colors.ToYellow("内部重定向失败: %v"), err) 187 | return originLink 188 | } 189 | defer resp.Body.Close() 190 | return finalLink 191 | } 192 | -------------------------------------------------------------------------------- /internal/service/emby/subtitles.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 9 | "github.com/AmbitiousJun/go-emby2alist/internal/web/cache" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // ProxySubtitles 字幕代理, 过期时间设置为 30 天 14 | func ProxySubtitles(c *gin.Context) { 15 | if c == nil { 16 | return 17 | } 18 | 19 | // 判断是否带有转码字幕参数 20 | alistPath := c.Query("alist_path") 21 | templateId := c.Query("template_id") 22 | subName := c.Query("sub_name") 23 | apiKey := c.Query(QueryApiKeyName) 24 | if strs.AllNotEmpty(alistPath, templateId, subName, apiKey) { 25 | u, _ := url.Parse("/videos/proxy_subtitle") 26 | u.RawQuery = c.Request.URL.RawQuery 27 | c.Redirect(http.StatusTemporaryRedirect, u.String()) 28 | return 29 | } 30 | 31 | c.Header(cache.HeaderKeyExpired, cache.Duration(time.Hour*24*30)) 32 | ProxyOrigin(c) 33 | } 34 | -------------------------------------------------------------------------------- /internal/service/emby/type.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | // MsInfo MediaSourceId 解析信息 4 | type MsInfo struct { 5 | Empty bool // 传递的 id 是否是个空值 6 | Transcode bool // 是否请求转码的资源 7 | OriginId string // 原始 MediaSourceId 8 | RawId string // 未经过解析的原始请求 Id 9 | TemplateId string // alist 中转码资源的模板 id 10 | Format string // 转码资源的格式, 比如:1920x1080 11 | SourceNamePrefix string // 转码资源名称前缀 12 | AlistPath string // 资源在 alist 中的地址 13 | } 14 | 15 | // ItemInfo emby 资源 item 解析信息 16 | type ItemInfo struct { 17 | Id string // item id 18 | MsInfo MsInfo // MediaSourceId 解析信息 19 | ApiKey string // emby 接口密钥 20 | ApiKeyType ApiKeyType // emby 接口密钥类型 21 | ApiKeyName string // emby 接口密钥名称 22 | PlaybackInfoUri string // item 信息查询接口 uri, 通过源服务器查询 23 | } 24 | -------------------------------------------------------------------------------- /internal/service/m3u8/info.go: -------------------------------------------------------------------------------- 1 | package m3u8 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/AmbitiousJun/go-emby2alist/internal/service/alist" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/service/emby" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 18 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 19 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 20 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 21 | ) 22 | 23 | // NewByContent 根据 m3u8 文本初始化一个 info 对象 24 | // 25 | // 如果文本中的 ts 地址是相对地址, 可通过 baseUrl 指定请求前缀 26 | func NewByContent(baseUrl, content string) (*Info, error) { 27 | info := Info{RemoteBase: baseUrl} 28 | 29 | // 逐行遍历文本 30 | scanner := bufio.NewScanner(strings.NewReader(content)) 31 | lineComments := make([]string, 0) 32 | for scanner.Scan() { 33 | line := scanner.Text() 34 | if strs.AnyEmpty(line) { 35 | continue 36 | } 37 | 38 | // 1 扫描到一行 ts 39 | if !strings.HasPrefix(line, "#") { 40 | if strings.HasPrefix(line, baseUrl) { 41 | line = strings.Replace(line, baseUrl, "", 1) 42 | for strings.HasPrefix(line, "/") { 43 | line = line[1:] 44 | } 45 | } 46 | tsInfo := TsInfo{Url: line, Comments: lineComments} 47 | info.RemoteTsInfos = append(info.RemoteTsInfos, &tsInfo) 48 | lineComments = make([]string, 0) 49 | continue 50 | } 51 | 52 | // 2 扫描到注释 53 | prefix := strings.Split(line, ":")[0] 54 | if _, ok := ParentHeadComments[prefix]; ok { 55 | info.HeadComments = append(info.HeadComments, line) 56 | continue 57 | } 58 | if _, ok := ParentTailComments[prefix]; ok { 59 | info.TailComments = append(info.TailComments, line) 60 | continue 61 | } 62 | lineComments = append(lineComments, line) 63 | } 64 | 65 | if scanner.Err() != nil { 66 | return nil, scanner.Err() 67 | } 68 | 69 | return &info, nil 70 | } 71 | 72 | // NewByRemote 从一个远程的 m3u8 地址中初始化 info 对象 73 | func NewByRemote(url string, header http.Header) (*Info, error) { 74 | // 1 解析 base 地址 75 | queryPos := strings.Index(url, "?") 76 | if queryPos == -1 { 77 | queryPos = len(url) 78 | } 79 | lastSepPos := strings.LastIndex(url[:queryPos], "/") 80 | if lastSepPos == -1 { 81 | return nil, fmt.Errorf("错误的 m3u8 地址: %s", url) 82 | } 83 | baseUrl := url[:lastSepPos+1] 84 | 85 | // 2 请求远程地址 86 | resp, err := https.Request(http.MethodGet, url, header, nil) 87 | if err != nil { 88 | return nil, fmt.Errorf("请求远程地址失败, url: %s, err: %v", url, err) 89 | } 90 | defer resp.Body.Close() 91 | 92 | // 3 判断是否为 m3u8 响应 93 | contentType := resp.Header.Get("Content-Type") 94 | if _, ok := ValidM3U8Contents[contentType]; !ok { 95 | return nil, fmt.Errorf("不是有效的 m3u8 响应: %s", contentType) 96 | } 97 | bodyBytes, err := io.ReadAll(resp.Body) 98 | if err != nil { 99 | return nil, fmt.Errorf("读取响应体失败: %v", err) 100 | } 101 | 102 | // 4 解析远程响应 103 | return NewByContent(baseUrl, string(bodyBytes)) 104 | } 105 | 106 | // GetTsLink 获取 ts 流的直链地址 107 | func (i *Info) GetTsLink(idx int) (string, bool) { 108 | size := len(i.RemoteTsInfos) 109 | if idx < 0 || idx >= size { 110 | return "", false 111 | } 112 | return i.RemoteBase + i.RemoteTsInfos[idx].Url, true 113 | } 114 | 115 | // Deprecated: MasterFunc 获取变体 m3u8 116 | // 117 | // 当 info 包含有字幕时, 需要调用这个方法返回 118 | func (i *Info) MasterFunc(cntMapper func() string, clientApiKey string) string { 119 | sb := strings.Builder{} 120 | sb.WriteString("#EXTM3U\n") 121 | sb.WriteString("#EXT-X-VERSION:3\n") 122 | // 写入字幕信息 123 | for _, subInfo := range i.Subtitles { 124 | u, _ := url.Parse("proxy_subtitle") 125 | q := u.Query() 126 | q.Set("alist_path", alist.PathEncode(i.AlistPath)) 127 | q.Set("template_id", i.TemplateId) 128 | q.Set("sub_name", urls.ResolveResourceName(subInfo.Url)) 129 | q.Set(emby.QueryApiKeyName, clientApiKey) 130 | u.RawQuery = q.Encode() 131 | cmt := fmt.Sprintf(`#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="%s",LANGUAGE="%s",URI="%s"`, subInfo.Lang, subInfo.Lang, u.String()) 132 | sb.WriteString(cmt + "\n") 133 | } 134 | sb.WriteString(`#EXT-X-STREAM-INF:SUBTITLES="subs"` + "\n") 135 | sb.WriteString(cntMapper()) 136 | return sb.String() 137 | } 138 | 139 | // ContentFunc 将 i 对象转换成 m3u8 文本 140 | // 141 | // tsMapper 函数可以将当前 info 中的 ts 地址映射为自定义地址 142 | // 两个参数分别是 ts 的索引和地址值 143 | func (i *Info) ContentFunc(tsMapper func(int, string) string) string { 144 | sb := strings.Builder{} 145 | 146 | // 1 写头注释 147 | for _, cmt := range i.HeadComments { 148 | sb.WriteString(cmt + "\n") 149 | } 150 | 151 | // 2 写 ts 152 | for idx, ti := range i.RemoteTsInfos { 153 | for _, cmt := range ti.Comments { 154 | sb.WriteString(cmt + "\n") 155 | } 156 | sb.WriteString(tsMapper(idx, ti.Url) + "\n") 157 | } 158 | 159 | // 3 写尾注释 160 | for _, cmt := range i.TailComments { 161 | sb.WriteString(cmt + "\n") 162 | } 163 | 164 | res := strings.TrimSuffix(sb.String(), "\n") 165 | 166 | return res 167 | } 168 | 169 | // ProxyContent 将 i 转换为 m3u8 本地代理文本 170 | func (i *Info) ProxyContent(main bool, routePrefix, clientApiKey string) string { 171 | baseRoute := strings.Builder{} 172 | if routePrefix != "" { 173 | baseRoute.WriteString(routePrefix) 174 | baseRoute.WriteString("/") 175 | } 176 | 177 | // 有内封字幕的资源, 切换为变体 m3u8 178 | if !main && len(i.Subtitles) > 0 { 179 | baseRoute.WriteString("proxy_playlist") 180 | return i.MasterFunc(func() string { 181 | u, _ := url.Parse(baseRoute.String()) 182 | q := u.Query() 183 | q.Set("alist_path", alist.PathEncode(i.AlistPath)) 184 | q.Set("template_id", i.TemplateId) 185 | q.Set(emby.QueryApiKeyName, clientApiKey) 186 | q.Set("type", "main") 187 | u.RawQuery = q.Encode() 188 | return u.String() 189 | }, clientApiKey) 190 | } 191 | 192 | baseRoute.WriteString("proxy_ts") 193 | return i.ContentFunc(func(idx int, _ string) string { 194 | u, _ := url.Parse(baseRoute.String()) 195 | q := u.Query() 196 | q.Set("idx", strconv.Itoa(idx)) 197 | q.Set("alist_path", alist.PathEncode(i.AlistPath)) 198 | q.Set("template_id", i.TemplateId) 199 | q.Set(emby.QueryApiKeyName, clientApiKey) 200 | u.RawQuery = q.Encode() 201 | return u.String() 202 | }) 203 | } 204 | 205 | // Content 将 i 转换为 m3u8 文本 206 | func (i *Info) Content() string { 207 | return i.ContentFunc(func(_ int, url string) string { 208 | return i.RemoteBase + url 209 | }) 210 | } 211 | 212 | // UpdateContent 从 alist 获取最新的 m3u8 并更新对象 213 | // 214 | // 通过 AlistPath 和 TemplateId 定位到唯一一个转码资源地址 215 | func (i *Info) UpdateContent() error { 216 | if i.AlistPath == "" || i.TemplateId == "" { 217 | return errors.New("参数为设置, 无法更新") 218 | } 219 | log.Printf(colors.ToPurple("更新 playlist, alistPath: %s, templateId: %s"), i.AlistPath, i.TemplateId) 220 | 221 | // 请求 alist 资源 222 | res := alist.FetchResource(alist.FetchInfo{ 223 | Path: i.AlistPath, 224 | UseTranscode: true, 225 | Format: i.TemplateId, 226 | }) 227 | if res.Code != http.StatusOK { 228 | return errors.New("请求 alist 失败: " + res.Msg) 229 | } 230 | 231 | // 解析地址 232 | newInfo, err := NewByRemote(res.Data.Url, nil) 233 | if err != nil { 234 | return fmt.Errorf("解析远程 m3u8 失败, url: %s, err: %v", res.Data, err) 235 | } 236 | 237 | // 拷贝最新数据 238 | i.RemoteBase = newInfo.RemoteBase 239 | i.HeadComments = append(([]string)(nil), newInfo.HeadComments...) 240 | i.TailComments = append(([]string)(nil), newInfo.TailComments...) 241 | i.RemoteTsInfos = append(([]*TsInfo)(nil), newInfo.RemoteTsInfos...) 242 | i.Subtitles = append(([]alist.SubtitleInfo)(nil), res.Data.Subtitles...) 243 | i.LastUpdate = time.Now().UnixMilli() 244 | return nil 245 | } 246 | -------------------------------------------------------------------------------- /internal/service/m3u8/m3u8.go: -------------------------------------------------------------------------------- 1 | package m3u8 2 | 3 | import ( 4 | "log" 5 | "sort" 6 | "sync" 7 | "time" 8 | 9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 10 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 11 | ) 12 | 13 | const ( 14 | 15 | // MaxPlaylistNum 在内存中最多维护的 m3u8 列表个数 16 | // 超出则淘汰最久没有读取的一个 17 | MaxPlaylistNum = 10 18 | 19 | // PreChanSize 预处理通道大小, 塞满时从头部开始淘汰 20 | PreChanSize = 1000 21 | ) 22 | 23 | func init() { 24 | go loopMaintainPlaylist() 25 | } 26 | 27 | // GetPlaylist 获取 m3u 播放列表, 返回 m3u 文本 28 | var GetPlaylist func(alistPath, templateId string, proxy, main bool, routePrefix, clientApiKey string) (string, bool) 29 | 30 | // GetTsLink 获取 m3u 播放列表中的某个 ts 链接 31 | var GetTsLink func(alistPath, templateId string, idx int) (string, bool) 32 | 33 | // GetSubtitleLink 获取字幕链接 34 | var GetSubtitleLink func(alistPath, templateId, subName string) (string, bool) 35 | 36 | // preMaintainInfoChan 预处理通道 37 | // 38 | // 外界将需要维护的信息放到这个通道中, 由 goroutine 单线程维护内存 39 | var preMaintainInfoChan = make(chan Info, PreChanSize) 40 | 41 | // preChanHandlingGroup 维护预处理通道的处理状态 42 | // 43 | // 当有新的任务加入通道时, group + 1 44 | // 当 gouroutine 处理完一个任务时, group - 1 45 | var preChanHandlingGroup = sync.WaitGroup{} 46 | 47 | // PushPlaylistAsync 将一个 alist 转码资源异步缓存到内存中 48 | func PushPlaylistAsync(info Info) { 49 | if info.AlistPath == "" || info.TemplateId == "" { 50 | return 51 | } 52 | info = Info{AlistPath: info.AlistPath, TemplateId: info.TemplateId} 53 | preChanHandlingGroup.Add(1) 54 | doneOnce := sync.OnceFunc(preChanHandlingGroup.Done) 55 | go func() { 56 | for { 57 | select { 58 | case preMaintainInfoChan <- info: 59 | return 60 | default: 61 | <-preMaintainInfoChan 62 | // 从通道中淘汰旧元素, 通道总大小不会改变 63 | doneOnce() 64 | } 65 | } 66 | }() 67 | } 68 | 69 | // loopMaintainPlaylist 由单独的 goroutine 执行 70 | // 71 | // 维护内存中的 m3u8 播放列表 72 | func loopMaintainPlaylist() { 73 | // map 记录播放列表, 用于快速响应客户端 74 | infoMap := map[string]*Info{} 75 | // arr 记录播放列表, 便于实现淘汰机制 76 | infoArr := make([]*Info, 0) 77 | 78 | // maintainDuration goroutine 维护 playlist 的间隔 79 | maintainDuration := time.Minute * 10 80 | // stopUpdateTimeMillis 超过这个时间未读, playlist 停止更新 81 | stopUpdateTimeMillis := (maintainDuration + time.Minute).Milliseconds() 82 | // removeTimeMillis 超过这个时间未读, playlist 被移除 83 | removeTimeMillis := time.Hour.Milliseconds() 84 | 85 | // publicApiUpdateMutex 对外部暴露的 api 的内部实现中 86 | // 如果涉及到更新的操作, 需要获取这个锁, 避免频繁请求 alist 87 | publicApiUpdateMutex := sync.Mutex{} 88 | 89 | // printErr 打印错误日志 90 | printErr := func(info *Info, err error) { 91 | log.Printf(colors.ToRed("playlist 更新失败, path: %s, template: %s, err: %v"), info.AlistPath, info.TemplateId, err) 92 | } 93 | 94 | // calcMapKey 计算 info 在 map 中的 key 95 | calcMapKey := func(info Info) string { 96 | return info.AlistPath + info.TemplateId 97 | } 98 | 99 | // beforeNow 判断一个时间是不是在当前时间之前 100 | beforeNow := func(millis int64) bool { 101 | return millis < time.Now().UnixMilli() 102 | } 103 | 104 | // queryInfo 查询内存中的 info 信息 105 | // 106 | // 如果内存中 map 已经能查询到 info 信息, 直接返回 107 | // 否则会等待预处理通道处理完毕后再次判断 108 | queryInfo := func(alistPath, templateId string) (info *Info) { 109 | key := calcMapKey(Info{AlistPath: alistPath, TemplateId: templateId}) 110 | var ok bool 111 | info, ok = infoMap[key] 112 | 113 | defer func() { 114 | if info == nil { 115 | return 116 | } 117 | // 如果当前 info 已经停止更新, 则手动触发更新 118 | if beforeNow(info.LastRead + stopUpdateTimeMillis) { 119 | publicApiUpdateMutex.Lock() 120 | defer publicApiUpdateMutex.Unlock() 121 | if beforeNow(info.LastRead + stopUpdateTimeMillis) { 122 | if err := info.UpdateContent(); err != nil { 123 | printErr(info, err) 124 | info = nil 125 | } 126 | } 127 | } 128 | // 更新最后读取时间 129 | info.LastRead = time.Now().UnixMilli() 130 | }() 131 | 132 | if ok { 133 | return 134 | } 135 | 136 | // 等待预处理通道处理完毕 137 | preChanHandlingGroup.Wait() 138 | 139 | info, ok = infoMap[key] 140 | if ok { 141 | return 142 | } 143 | return nil 144 | } 145 | 146 | GetPlaylist = func(alistPath, templateId string, proxy, main bool, routePrefix, clientApiKey string) (string, bool) { 147 | info := queryInfo(alistPath, templateId) 148 | if info == nil { 149 | return "", false 150 | } 151 | if proxy { 152 | return info.ProxyContent(main, routePrefix, clientApiKey), true 153 | } 154 | return info.Content(), true 155 | } 156 | 157 | GetTsLink = func(alistPath, templateId string, idx int) (string, bool) { 158 | info := queryInfo(alistPath, templateId) 159 | if info == nil { 160 | return "", false 161 | } 162 | return info.GetTsLink(idx) 163 | } 164 | 165 | GetSubtitleLink = func(alistPath, templateId, subName string) (string, bool) { 166 | info := queryInfo(alistPath, templateId) 167 | if info == nil { 168 | return "", false 169 | } 170 | for _, subInfo := range info.Subtitles { 171 | curSubName := urls.ResolveResourceName(subInfo.Url) 172 | if curSubName == subName { 173 | return subInfo.Url, true 174 | } 175 | } 176 | return "", false 177 | } 178 | 179 | // removeInfo 删除内存中的 info 信息 180 | removeInfo := func(key string) { 181 | info, ok := infoMap[key] 182 | if !ok { 183 | return 184 | } 185 | delete(infoMap, key) 186 | for i, arrInfo := range infoArr { 187 | if arrInfo == info { 188 | infoArr = append(infoArr[:i], infoArr[i+1:]...) 189 | break 190 | } 191 | } 192 | } 193 | 194 | // updateAll 更新内存中的 info 信息 195 | // 196 | // 如果 lastRead 不满足条件, 被淘汰 197 | updateAll := func() { 198 | // 复制一份 arr 199 | cpArr := append(([]*Info)(nil), infoArr...) 200 | tot, active := len(cpArr), 0 201 | 202 | for _, info := range cpArr { 203 | key := calcMapKey(Info{AlistPath: info.AlistPath, TemplateId: info.TemplateId}) 204 | 205 | // 长时间未读, 移除 206 | if beforeNow(info.LastUpdate + removeTimeMillis) { 207 | removeInfo(key) 208 | log.Printf(colors.ToGray("playlist 长时间未被更新, 已移除, alistPath: %s, templateId: %s"), info.AlistPath, info.TemplateId) 209 | tot-- 210 | continue 211 | } 212 | 213 | // 超过指定时间未读, 不更新 214 | if beforeNow(info.LastRead + stopUpdateTimeMillis) { 215 | continue 216 | } 217 | 218 | // 如果更新失败, 移除 219 | active++ 220 | if err := info.UpdateContent(); err != nil { 221 | printErr(info, err) 222 | removeInfo(key) 223 | tot-- 224 | active-- 225 | } 226 | } 227 | 228 | if len(cpArr) > 0 { 229 | log.Printf(colors.ToPurple("当前正在维护的 playlist 个数: %d, 活跃个数: %d"), tot, active) 230 | } 231 | } 232 | 233 | // addInfo 添加 info 到内存中 234 | addInfo := func(preInfo Info) { 235 | if preInfo.AlistPath == "" || preInfo.TemplateId == "" { 236 | return 237 | } 238 | key := calcMapKey(preInfo) 239 | 240 | // 如果内存已存在 key, 复用 241 | info, exist := infoMap[key] 242 | if !exist { 243 | info = &preInfo 244 | } 245 | 246 | // 初始化 Info 信息, 并更新 247 | if err := info.UpdateContent(); err != nil { 248 | printErr(info, err) 249 | removeInfo(key) 250 | return 251 | } 252 | info.LastRead = time.Now().UnixMilli() 253 | 254 | // 维护到内存中 255 | if !exist { 256 | infoMap[key] = info 257 | infoArr = append(infoArr, info) 258 | } 259 | 260 | if len(infoArr) <= MaxPlaylistNum { 261 | return 262 | } 263 | // 内存满, 淘汰旧内存 264 | sort.Slice(infoArr, func(i, j int) bool { 265 | return infoArr[i].LastRead < infoArr[j].LastRead 266 | }) 267 | toDeletes := make([]*Info, len(infoArr)-MaxPlaylistNum) 268 | copy(toDeletes, infoArr) 269 | for _, toDel := range toDeletes { 270 | removeInfo(calcMapKey(Info{AlistPath: toDel.AlistPath, TemplateId: toDel.TemplateId})) 271 | log.Printf(colors.ToGray("playlist 被淘汰并从内存中移除, alistPath: %s, templateId: %s"), toDel.AlistPath, toDel.TemplateId) 272 | } 273 | } 274 | 275 | // 定时维护一次内存中的数据 276 | t := time.NewTicker(maintainDuration) 277 | defer t.Stop() 278 | 279 | for { 280 | select { 281 | case <-t.C: 282 | updateAll() 283 | case preInfo := <-preMaintainInfoChan: 284 | addInfo(preInfo) 285 | preChanHandlingGroup.Done() 286 | } 287 | } 288 | 289 | } 290 | -------------------------------------------------------------------------------- /internal/service/m3u8/m3u8_test.go: -------------------------------------------------------------------------------- 1 | package m3u8_test 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 8 | "github.com/AmbitiousJun/go-emby2alist/internal/service/m3u8" 9 | ) 10 | 11 | func TestPlaylistCache(t *testing.T) { 12 | config.ReadFromFile("../../../config.yml") 13 | info := m3u8.Info{ 14 | AlistPath: "/运动/安小雨跳绳课 (2021)/安小雨跳绳课.S01E01.3000次.25分钟.1080p.mp4", 15 | TemplateId: "FHD", 16 | } 17 | 18 | // 注册 playlist 19 | m3u8.PushPlaylistAsync(info) 20 | 21 | // 获取 playlist 22 | m3uContent, ok := m3u8.GetPlaylist(info.AlistPath, info.TemplateId, true, true, "", "") 23 | if !ok { 24 | log.Fatal("获取 m3u 失败") 25 | } 26 | log.Println(m3uContent) 27 | 28 | // 获取 ts 29 | log.Printf("\n\n\n") 30 | log.Println("获取 162 ts: ") 31 | log.Println(m3u8.GetTsLink(info.AlistPath, info.TemplateId, 162)) 32 | 33 | // 获取 ts 34 | log.Printf("\n\n\n") 35 | log.Println("获取 150 ts: ") 36 | log.Println(m3u8.GetTsLink(info.AlistPath, info.TemplateId, 150)) 37 | } 38 | -------------------------------------------------------------------------------- /internal/service/m3u8/proxy.go: -------------------------------------------------------------------------------- 1 | package m3u8 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/AmbitiousJun/go-emby2alist/internal/service/alist" 11 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 12 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 13 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 14 | 15 | "github.com/gin-gonic/gin" 16 | ) 17 | 18 | // baseCheck 对代理请求参数作基本校验 19 | func baseCheck(c *gin.Context) (ProxyParams, error) { 20 | if c.Request.Method != http.MethodGet { 21 | return ProxyParams{}, errors.New("仅支持 GET") 22 | } 23 | 24 | var params ProxyParams 25 | if err := c.ShouldBindQuery(¶ms); err != nil { 26 | return ProxyParams{}, err 27 | } 28 | 29 | params.AlistPath = alist.PathDecode(params.AlistPath) 30 | 31 | if params.AlistPath == "" || params.TemplateId == "" || params.ApiKey == "" { 32 | return ProxyParams{}, errors.New("参数不足") 33 | } 34 | 35 | return params, nil 36 | } 37 | 38 | // ProxyPlaylist 代理 m3u8 转码地址 39 | func ProxyPlaylist(c *gin.Context) { 40 | params, err := baseCheck(c) 41 | if err != nil { 42 | log.Printf(colors.ToRed("代理 m3u8 失败: %v"), err.Error()) 43 | c.String(http.StatusBadRequest, "代理 m3u8 失败, 请检查日志") 44 | return 45 | } 46 | 47 | okContent := func(content string) { 48 | c.Header("Content-Type", "application/vnd.apple.mpegurl") 49 | c.String(http.StatusOK, content) 50 | } 51 | 52 | // ts 切片使用绝对路径 53 | routePrefix := https.ClientRequestHost(c) + "/videos" 54 | 55 | m3uContent, ok := GetPlaylist(params.AlistPath, params.TemplateId, true, true, routePrefix, params.ApiKey) 56 | if ok { 57 | okContent(m3uContent) 58 | return 59 | } 60 | 61 | // 获取失败, 将当前请求的地址加入到预处理通道 62 | PushPlaylistAsync(Info{AlistPath: params.AlistPath, TemplateId: params.TemplateId}) 63 | 64 | // 重新获取一次 65 | m3uContent, ok = GetPlaylist(params.AlistPath, params.TemplateId, true, true, routePrefix, params.ApiKey) 66 | if ok { 67 | okContent(m3uContent) 68 | return 69 | } 70 | c.String(http.StatusBadRequest, "获取不到播放列表, 请检查日志") 71 | } 72 | 73 | // ProxyTsLink 代理 ts 直链地址 74 | func ProxyTsLink(c *gin.Context) { 75 | params, err := baseCheck(c) 76 | if err != nil { 77 | log.Printf(colors.ToRed("代理 ts 失败: %v"), err) 78 | c.String(http.StatusBadRequest, "代理 ts 失败, 请检查日志") 79 | return 80 | } 81 | 82 | idx, err := strconv.Atoi(params.IdxStr) 83 | if err != nil || idx < 0 { 84 | c.String(http.StatusBadRequest, "无效 idx") 85 | return 86 | } 87 | 88 | okRedirect := func(link string) { 89 | log.Printf(colors.ToGreen("重定向 ts: %s"), link) 90 | c.Redirect(http.StatusTemporaryRedirect, link) 91 | } 92 | 93 | tsLink, ok := GetTsLink(params.AlistPath, params.TemplateId, idx) 94 | if ok { 95 | okRedirect(tsLink) 96 | return 97 | } 98 | 99 | // 获取失败, 将当前请求的地址加入到预处理通道 100 | PushPlaylistAsync(Info{AlistPath: params.AlistPath, TemplateId: params.TemplateId}) 101 | 102 | tsLink, ok = GetTsLink(params.AlistPath, params.TemplateId, idx) 103 | if ok { 104 | okRedirect(tsLink) 105 | return 106 | } 107 | c.String(http.StatusBadRequest, "获取不到 ts, 请检查日志") 108 | } 109 | 110 | // ProxySubtitle 代理字幕请求 111 | func ProxySubtitle(c *gin.Context) { 112 | params, err := baseCheck(c) 113 | if err != nil { 114 | log.Printf(colors.ToRed("代理字幕失败: %v"), err) 115 | c.String(http.StatusBadRequest, "代理字幕失败, 请检查日志") 116 | return 117 | } 118 | 119 | subName := c.Query("sub_name") 120 | if strs.AnyEmpty(subName) { 121 | c.String(http.StatusBadRequest, "代理字幕失败, 缺少 sub_name 参数") 122 | return 123 | } 124 | 125 | proxySubtitle := func(link string) { 126 | log.Printf(colors.ToGreen("代理字幕: %s"), link) 127 | resp, err := https.Request(http.MethodGet, link, nil, nil) 128 | if err != nil { 129 | log.Printf(colors.ToRed("代理字幕失败: %v"), err) 130 | c.String(http.StatusInternalServerError, "代理字幕失败, 请检查日志") 131 | return 132 | } 133 | defer resp.Body.Close() 134 | https.CloneHeader(c, resp.Header) 135 | c.Status(resp.StatusCode) 136 | if _, err = io.Copy(c.Writer, resp.Body); err != nil { 137 | log.Printf(colors.ToRed("代理字幕失败: %v"), err) 138 | c.String(http.StatusInternalServerError, "代理字幕失败, 请检查日志") 139 | return 140 | } 141 | } 142 | 143 | subtitleLink, ok := GetSubtitleLink(params.AlistPath, params.TemplateId, subName) 144 | if ok { 145 | proxySubtitle(subtitleLink) 146 | return 147 | } 148 | 149 | // 获取失败, 将当前请求的地址加入到预处理通道 150 | PushPlaylistAsync(Info{AlistPath: params.AlistPath, TemplateId: params.TemplateId}) 151 | 152 | subtitleLink, ok = GetSubtitleLink(params.AlistPath, params.TemplateId, subName) 153 | if ok { 154 | proxySubtitle(subtitleLink) 155 | return 156 | } 157 | c.String(http.StatusBadRequest, "获取不到字幕") 158 | } 159 | -------------------------------------------------------------------------------- /internal/service/m3u8/type.go: -------------------------------------------------------------------------------- 1 | package m3u8 2 | 3 | import "github.com/AmbitiousJun/go-emby2alist/internal/service/alist" 4 | 5 | // ParentHeadComments 记录文件头注释 6 | var ParentHeadComments = map[string]struct{}{ 7 | "#EXTM3U": {}, "#EXT-X-VERSION": {}, "#EXT-X-MEDIA-SEQUENCE": {}, 8 | "#EXT-X-TARGETDURATION": {}, "#EXT-X-MEDIA": {}, "#EXT-X-INDEPENDENT-SEGMENTS": {}, 9 | "#EXT-X-STREAM-INF": {}, 10 | } 11 | 12 | // ParentTailComments 记录文件尾注释 13 | var ParentTailComments = map[string]struct{}{ 14 | "#EXT-X-ENDLIST": {}, 15 | } 16 | 17 | // 响应头中,有效的 m3u8 Content-Type 属性 18 | var ValidM3U8Contents = map[string]struct{}{ 19 | "application/vnd.apple.mpegurl": {}, 20 | "application/x-mpegurl": {}, 21 | "audio/x-mpegurl": {}, 22 | "application/octet-stream": {}, 23 | } 24 | 25 | // Info 记录一个 m3u8 相关信息 26 | type Info struct { 27 | AlistPath string // 资源在 alist 中的绝对路径 28 | TemplateId string // 转码资源模板 id 29 | Subtitles []alist.SubtitleInfo // 字幕信息, 如果一个资源是含有字幕的, 会返回变体 m3u8 30 | RemoteBase string // 远程 m3u8 地址前缀 31 | HeadComments []string // 头注释信息 32 | TailComments []string // 尾注释信息 33 | RemoteTsInfos []*TsInfo // 远程的 ts URL 列表, 用于重定向 34 | 35 | // LastRead 客户端最后读取的时间戳 (毫秒) 36 | // 37 | // 超过 30 分钟未读取, 程序停止更新; 38 | // 超过 12 小时未读取, m3u info 被移除 39 | LastRead int64 40 | 41 | // LastUpdate 程序最后的更新时间戳 (毫秒) 42 | // 43 | // 客户端来读取时, 如果 m3u info 已经超过 10 分钟没有更新了 44 | // 触发更新机制之后, 再返回最新的地址 45 | LastUpdate int64 46 | } 47 | 48 | // TsInfo 记录一个 ts 相关信息 49 | type TsInfo struct { 50 | Comments []string // 注释信息 51 | Url string // 远程流请求地址 52 | } 53 | 54 | // ProxyParams 代理请求接收参数 55 | type ProxyParams struct { 56 | AlistPath string `form:"alist_path"` 57 | TemplateId string `form:"template_id"` 58 | Remote string `form:"remote"` 59 | Type string `form:"type"` 60 | ApiKey string `form:"api_key"` 61 | IdxStr string `form:"idx"` 62 | } 63 | -------------------------------------------------------------------------------- /internal/service/path/path.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 10 | "github.com/AmbitiousJun/go-emby2alist/internal/service/alist" 11 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 12 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 13 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 14 | ) 15 | 16 | // AlistPathRes 路径转换结果 17 | type AlistPathRes struct { 18 | 19 | // Success 转换是否成功 20 | Success bool 21 | 22 | // Path 转换后的路径 23 | Path string 24 | 25 | // Range 遍历所有 Alist 根路径生成的子路径 26 | Range func() ([]string, error) 27 | } 28 | 29 | // Emby2Alist Emby 资源路径转 Alist 资源路径 30 | func Emby2Alist(embyPath string) AlistPathRes { 31 | pathRoutes := strings.Builder{} 32 | pathRoutes.WriteString("[") 33 | pathRoutes.WriteString("\n1 => " + embyPath) 34 | 35 | embyPath = urls.TransferSlash(embyPath) 36 | pathRoutes.WriteString("\n2 => " + embyPath) 37 | 38 | embyMount := config.C.Emby.MountPath 39 | alistFilePath := strings.TrimPrefix(embyPath, embyMount) 40 | pathRoutes.WriteString("\n3 => " + alistFilePath) 41 | 42 | if mapPath, ok := config.C.Path.MapEmby2Alist(alistFilePath); ok { 43 | alistFilePath = mapPath 44 | pathRoutes.WriteString("\n4 => " + alistFilePath) 45 | } 46 | pathRoutes.WriteString("\n]") 47 | log.Printf(colors.ToGray("embyPath 转换路径: %s"), pathRoutes.String()) 48 | 49 | rangeFunc := func() ([]string, error) { 50 | filePath, err := SplitFromSecondSlash(alistFilePath) 51 | if err != nil { 52 | return nil, fmt.Errorf("alistFilePath 解析异常: %s, error: %v", alistFilePath, err) 53 | } 54 | 55 | res := alist.FetchFsList("/", nil) 56 | if res.Code != http.StatusOK { 57 | return nil, fmt.Errorf("请求 alist fs list 接口异常: %s", res.Msg) 58 | } 59 | 60 | paths := make([]string, 0) 61 | content, ok := res.Data.Attr("content").Done() 62 | if !ok || content.Type() != jsons.JsonTypeArr { 63 | return nil, fmt.Errorf("alist fs list 接口响应异常, 原始响应: %v", jsons.NewByObj(res)) 64 | } 65 | 66 | content.RangeArr(func(_ int, value *jsons.Item) error { 67 | if value.Attr("is_dir").Val() == false { 68 | return nil 69 | } 70 | newPath := fmt.Sprintf("/%s%s", value.Attr("name").Val(), filePath) 71 | paths = append(paths, newPath) 72 | return nil 73 | }) 74 | 75 | return paths, nil 76 | } 77 | 78 | return AlistPathRes{ 79 | Success: true, 80 | Path: alistFilePath, 81 | Range: rangeFunc, 82 | } 83 | } 84 | 85 | // SplitFromSecondSlash 找到给定字符串 str 中第二个 '/' 字符的位置 86 | // 并以该位置为首字符切割剩余的子串返回 87 | func SplitFromSecondSlash(str string) (string, error) { 88 | str = urls.TransferSlash(str) 89 | firstIdx := strings.Index(str, "/") 90 | if firstIdx == -1 { 91 | return "", fmt.Errorf("字符串不包含 /: %s", str) 92 | } 93 | 94 | secondIdx := strings.Index(str[firstIdx+1:], "/") 95 | if secondIdx == -1 { 96 | return "", fmt.Errorf("字符串只有单个 /: %s", str) 97 | } 98 | 99 | return str[secondIdx+firstIdx+1:], nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/service/path/path_test.go: -------------------------------------------------------------------------------- 1 | package path_test 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | "github.com/AmbitiousJun/go-emby2alist/internal/service/path" 8 | ) 9 | 10 | func TestSplit(t *testing.T) { 11 | str := `H:\Phim4K\The.Lockdown.2024.2160p.WEB-DL.DDP5.1.DV.HDR.H.265-FLUX.mkv` 12 | log.Println(path.SplitFromSecondSlash(str)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/setup/setup.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | // LogColorEnbale 是否启用日志彩色输出 4 | var LogColorEnbale = false 5 | -------------------------------------------------------------------------------- /internal/util/colors/colors.go: -------------------------------------------------------------------------------- 1 | package colors 2 | 3 | import ( 4 | "github.com/AmbitiousJun/go-emby2alist/internal/setup" 5 | ) 6 | 7 | // 日志颜色输出常量 8 | const ( 9 | Blue = "\x1b[38;2;090;156;248m" 10 | Green = "\x1b[38;2;126;192;080m" 11 | Yellow = "\x1b[38;2;220;165;080m" 12 | Red = "\x1b[38;2;228;116;112m" 13 | Purple = "\x1b[38;2;160;186;250m" 14 | Gray = "\x1b[38;2;145;147;152m" 15 | 16 | reset = "\x1b[0m" 17 | ) 18 | 19 | // ToBlue 将字符串转成蓝色 20 | func ToBlue(str string) string { 21 | return wrapColor(Blue, str) 22 | } 23 | 24 | // ToGreen 将字符串转成绿色 25 | func ToGreen(str string) string { 26 | return wrapColor(Green, str) 27 | } 28 | 29 | // ToYellow 将字符串转成黄色 30 | func ToYellow(str string) string { 31 | return wrapColor(Yellow, str) 32 | } 33 | 34 | // ToRed 将字符串转成红色 35 | func ToRed(str string) string { 36 | return wrapColor(Red, str) 37 | } 38 | 39 | // ToPurple 将字符串转成紫色 40 | func ToPurple(str string) string { 41 | return wrapColor(Purple, str) 42 | } 43 | 44 | // ToGray 将字符串转成灰色 45 | func ToGray(str string) string { 46 | return wrapColor(Gray, str) 47 | } 48 | 49 | // wrapColor 将字符串 str 包裹上指定颜色的 ANSI 字符 50 | // 51 | // 如果用户关闭了颜色输出, 则直接返回原字符串 52 | func wrapColor(color, str string) string { 53 | if !setup.LogColorEnbale { 54 | return str 55 | } 56 | return color + str + reset 57 | } 58 | -------------------------------------------------------------------------------- /internal/util/encrypts/encrypts.go: -------------------------------------------------------------------------------- 1 | package encrypts 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | ) 7 | 8 | // Md5Hash 对字符串 raw 进行 md5 哈希运算, 返回十六进制 9 | func Md5Hash(raw string) string { 10 | hash := md5.New() 11 | hash.Write([]byte(raw)) 12 | return hex.EncodeToString(hash.Sum(nil)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/util/https/gin.go: -------------------------------------------------------------------------------- 1 | package https 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | // ExtractReqBody 克隆并提取请求体 17 | // 不影响 c 对象之后再次读取请求体 18 | func ExtractReqBody(c *gin.Context) ([]byte, error) { 19 | if c == nil { 20 | return nil, nil 21 | } 22 | bodyBytes, err := io.ReadAll(c.Request.Body) 23 | if err != nil { 24 | return nil, err 25 | } 26 | c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 27 | return bodyBytes, nil 28 | } 29 | 30 | // ClientRequestHost 获取客户端请求的 Host 31 | func ClientRequestHost(c *gin.Context) string { 32 | if c == nil { 33 | return "" 34 | } 35 | 36 | scheme := "http" 37 | if c.Request.TLS != nil { 38 | scheme = "https" 39 | } 40 | 41 | return fmt.Sprintf("%s://%s", scheme, c.Request.Host) 42 | } 43 | 44 | // ClientRequestUrl 获取客户端请求的完整地址 45 | func ClientRequestUrl(c *gin.Context) string { 46 | return fmt.Sprintf("%s%s", ClientRequestHost(c), c.Request.URL.String()) 47 | } 48 | 49 | // IsErrorResponse 判断一个请求响应是否是错误响应 50 | // 51 | // 判断标准是响应码以 4xx 5xx 开头 52 | func IsErrorResponse(c *gin.Context) bool { 53 | if c == nil { 54 | return true 55 | } 56 | code := c.Writer.Status() 57 | str := strconv.Itoa(code) 58 | return strings.HasPrefix(str, "4") || strings.HasPrefix(str, "5") 59 | } 60 | 61 | // CloneHeader 克隆 http 头部到 gin 的响应头中 62 | func CloneHeader(c *gin.Context, header http.Header) { 63 | if c == nil || header == nil { 64 | return 65 | } 66 | for key, values := range header { 67 | c.Writer.Header().Del(key) 68 | for _, value := range values { 69 | c.Header(key, value) 70 | } 71 | } 72 | } 73 | 74 | // ProxyRequest 代理请求 75 | func ProxyRequest(c *gin.Context, remote string, withUri bool) error { 76 | if c == nil || remote == "" { 77 | return errors.New("参数为空") 78 | } 79 | 80 | if withUri { 81 | remote = remote + c.Request.URL.String() 82 | } 83 | 84 | // 1 解析远程地址 85 | rmtUrl, err := url.Parse(remote) 86 | if err != nil { 87 | return fmt.Errorf("解析远程地址失败: %v", err) 88 | } 89 | 90 | // 2 拷贝 query 参数 91 | rmtUrl.RawQuery = c.Request.URL.RawQuery 92 | 93 | // 3 创建请求 94 | var bodyBuffer io.Reader = nil 95 | if c.Request.Body != nil { 96 | reqBodyBytes, err := io.ReadAll(c.Request.Body) 97 | if err != nil { 98 | return fmt.Errorf("读取请求体失败: %v", err) 99 | } 100 | if len(reqBodyBytes) > 0 { 101 | bodyBuffer = bytes.NewBuffer(reqBodyBytes) 102 | } 103 | } 104 | 105 | req, err := http.NewRequest(c.Request.Method, rmtUrl.String(), bodyBuffer) 106 | if err != nil { 107 | return fmt.Errorf("初始化请求失败: %v", err) 108 | } 109 | 110 | // 4 拷贝请求头 111 | req.Header = c.Request.Header 112 | 113 | // 5 发起请求 114 | resp, err := client.Do(req) 115 | if err != nil { 116 | return fmt.Errorf("请求失败: %v", err) 117 | } 118 | defer resp.Body.Close() 119 | 120 | // 6 回写响应头 121 | c.Status(resp.StatusCode) 122 | for key, values := range resp.Header { 123 | for _, value := range values { 124 | c.Header(key, value) 125 | } 126 | } 127 | 128 | // 7 回写响应体 129 | io.Copy(c.Writer, resp.Body) 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/util/https/https.go: -------------------------------------------------------------------------------- 1 | package https 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "net/http" 12 | "path" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | const ( 19 | 20 | // MaxRedirectDepth 重定向的最大深度 21 | MaxRedirectDepth = 10 22 | ) 23 | 24 | var client *http.Client 25 | 26 | // RedirectCodes 有重定向含义的 http 响应码 27 | var RedirectCodes = [4]int{http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} 28 | 29 | func init() { 30 | client = &http.Client{ 31 | Transport: &http.Transport{ 32 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 33 | // 建立连接 1 分钟超时 34 | Dial: (&net.Dialer{Timeout: time.Minute}).Dial, 35 | // 接收数据 5 分钟超时 36 | ResponseHeaderTimeout: time.Minute * 5, 37 | }, 38 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 39 | return http.ErrUseLastResponse 40 | }, 41 | } 42 | } 43 | 44 | // IsRedirectCode 判断 http code 是否是重定向 45 | // 46 | // 301, 302, 307, 308 47 | func IsRedirectCode(code int) bool { 48 | for _, valid := range RedirectCodes { 49 | if code == valid { 50 | return true 51 | } 52 | } 53 | return false 54 | } 55 | 56 | // IsSuccessCode 判断 http code 是否为成功状态 57 | func IsSuccessCode(code int) bool { 58 | codeStr := strconv.Itoa(code) 59 | return strings.HasPrefix(codeStr, "2") 60 | } 61 | 62 | // IsErrorCode 判断 http code 是否为错误状态 63 | func IsErrorCode(code int) bool { 64 | codeStr := strconv.Itoa(code) 65 | return strings.HasPrefix(codeStr, "4") || strings.HasPrefix(codeStr, "5") 66 | } 67 | 68 | // MapBody 将 map 转换为 ReadCloser 流 69 | func MapBody(body map[string]any) io.ReadCloser { 70 | if body == nil { 71 | return nil 72 | } 73 | bodyBytes, err := json.Marshal(body) 74 | if err != nil { 75 | log.Printf("MapBody 转换失败, body: %v, err : %v", body, err) 76 | return nil 77 | } 78 | return io.NopCloser(bytes.NewBuffer(bodyBytes)) 79 | } 80 | 81 | // Request 发起 http 请求获取响应 82 | func Request(method, url string, header http.Header, body io.ReadCloser) (*http.Response, error) { 83 | _, resp, err := RequestRedirect(method, url, header, body, true) 84 | return resp, err 85 | } 86 | 87 | // RequestRedirect 发起 http 请求获取响应 88 | // 89 | // 如果一个请求有多次重定向并且进行了 autoRedirect, 90 | // 则最后一次重定向的 url 会作为第一个参数返回 91 | func RequestRedirect(method, url string, header http.Header, body io.ReadCloser, autoRedirect bool) (string, *http.Response, error) { 92 | var inner func(method, url string, header http.Header, body io.ReadCloser, autoRedirect bool, depth int) (string, *http.Response, error) 93 | inner = func(method, url string, header http.Header, body io.ReadCloser, autoRedirect bool, depth int) (string, *http.Response, error) { 94 | if depth >= MaxRedirectDepth { 95 | return url, nil, fmt.Errorf("重定向次数过多: %s", url) 96 | } 97 | 98 | // 1 转换请求 99 | var bodyBytes []byte 100 | if body != nil { 101 | var err error 102 | if bodyBytes, err = io.ReadAll(body); err != nil { 103 | return "", nil, fmt.Errorf("读取请求体失败: %v", err) 104 | } 105 | } 106 | req, err := http.NewRequest(method, url, bytes.NewBuffer(bodyBytes)) 107 | if err != nil { 108 | return "", nil, fmt.Errorf("创建请求失败: %v", err) 109 | } 110 | req.Header = header 111 | 112 | // 2 发出请求 113 | resp, err := client.Do(req) 114 | if err != nil { 115 | return url, resp, err 116 | } 117 | 118 | // 3 对重定向响应的处理 119 | if !autoRedirect || !IsRedirectCode(resp.StatusCode) { 120 | return url, resp, err 121 | } 122 | loc := resp.Header.Get("Location") 123 | newBody := io.NopCloser(bytes.NewBuffer(bodyBytes)) 124 | 125 | if strings.HasPrefix(loc, "http") { 126 | return inner(method, loc, header, newBody, autoRedirect, depth+1) 127 | } 128 | 129 | if strings.HasPrefix(loc, "/") { 130 | loc = fmt.Sprintf("%s://%s%s", req.URL.Scheme, req.URL.Host, loc) 131 | return inner(method, loc, header, newBody, autoRedirect, depth+1) 132 | } 133 | 134 | dirPath := path.Dir(req.URL.Path) 135 | loc = fmt.Sprintf("%s://%s%s/%s", req.URL.Scheme, req.URL.Host, dirPath, loc) 136 | return inner(method, loc, header, newBody, autoRedirect, depth+1) 137 | } 138 | 139 | return inner(method, url, header, body, autoRedirect, 0) 140 | } 141 | -------------------------------------------------------------------------------- /internal/util/https/https_test.go: -------------------------------------------------------------------------------- 1 | package https_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "path" 8 | "testing" 9 | ) 10 | 11 | func TestRelativeRedirect(t *testing.T) { 12 | req, err := http.NewRequest(http.MethodGet, "https://example.com/user/addr/index.html", nil) 13 | if err != nil { 14 | log.Fatalf("创建请求失败: %v", err) 15 | } 16 | loc := "new_addr/a/test.mp4" 17 | 18 | dirPath := path.Dir(req.URL.Path) 19 | loc = fmt.Sprintf("%s://%s%s/%s", req.URL.Scheme, req.URL.Host, dirPath, loc) 20 | log.Println(loc) 21 | } 22 | -------------------------------------------------------------------------------- /internal/util/jsons/deep_get.go: -------------------------------------------------------------------------------- 1 | package jsons 2 | 3 | // TempItem 临时暂存 Item 对象 4 | type TempItem struct { 5 | 6 | // item 临时对象 7 | item *Item 8 | } 9 | 10 | // Attr 获取对象 item 某个 key 的值 11 | // 如果需要立即获得 *Item 对象, 需要链式调用 Done() 方法获取 12 | func (ti *TempItem) Attr(key string) *TempItem { 13 | if ti.item == nil { 14 | return ti 15 | } 16 | if ti.item.jType != JsonTypeObj { 17 | ti.item = nil 18 | return ti 19 | } 20 | if subI, ok := ti.item.obj[key]; ok { 21 | ti.item = subI 22 | } else { 23 | ti.item = nil 24 | } 25 | return ti 26 | } 27 | 28 | // Idx 获取数组 item 某个 index 的值 29 | // 如果需要立即获得 *Item 对象, 需要链式调用 Done() 方法获取 30 | func (ti *TempItem) Idx(index int) *TempItem { 31 | if ti.item == nil { 32 | return ti 33 | } 34 | if ti.item.jType != JsonTypeArr { 35 | ti.item = nil 36 | return ti 37 | } 38 | if index < 0 || index >= len(ti.item.arr) { 39 | ti.item = nil 40 | return ti 41 | } 42 | ti.item = ti.item.arr[index] 43 | return ti 44 | } 45 | 46 | // Done 获取链式调用后的 JSON 值 47 | func (ti *TempItem) Done() (*Item, bool) { 48 | if ti.item == nil { 49 | return nil, false 50 | } 51 | return ti.item, true 52 | } 53 | 54 | // Bool 获取链式调用后的 bool 值 55 | func (ti *TempItem) Bool() (bool, bool) { 56 | if ti.item == nil || ti.item.jType != JsonTypeVal { 57 | return false, false 58 | } 59 | if val, ok := ti.item.val.(bool); ok { 60 | return val, true 61 | } 62 | return false, false 63 | } 64 | 65 | // Int 获取链式调用后的 int 值 66 | func (ti *TempItem) Int() (int, bool) { 67 | if ti.item == nil || ti.item.jType != JsonTypeVal { 68 | return 0, false 69 | } 70 | if val, ok := ti.item.val.(int); ok { 71 | return val, true 72 | } 73 | return 0, false 74 | } 75 | 76 | // Int64 获取链式调用后的 int64 值 77 | func (ti *TempItem) Int64() (int64, bool) { 78 | if ti.item == nil || ti.item.jType != JsonTypeVal { 79 | return 0, false 80 | } 81 | if val, ok := ti.item.val.(int); ok { 82 | return int64(val), true 83 | } 84 | if val, ok := ti.item.val.(int64); ok { 85 | return val, true 86 | } 87 | return 0, false 88 | } 89 | 90 | // Float 获取链式调用后的 float 值 91 | func (ti *TempItem) Float() (float64, bool) { 92 | if ti.item == nil || ti.item.jType != JsonTypeVal { 93 | return 0, false 94 | } 95 | if val, ok := ti.item.val.(float64); ok { 96 | return val, true 97 | } 98 | return 0, false 99 | } 100 | 101 | // String 获取链式调用后的 string 值 102 | func (ti *TempItem) String() (string, bool) { 103 | if ti.item == nil || ti.item.jType != JsonTypeVal { 104 | return "", false 105 | } 106 | if val, ok := ti.item.val.(string); ok { 107 | return val, true 108 | } 109 | return "", false 110 | } 111 | 112 | // Val 获取链式调用后的 val 值, 类型不匹配时返回 nil 113 | func (ti *TempItem) Val() any { 114 | if ti.item == nil || ti.item.jType != JsonTypeVal { 115 | return nil 116 | } 117 | return ti.item.val 118 | } 119 | 120 | // Set 设置当前链式调用后的 val 值, 类型不匹配时不作更改 121 | func (ti *TempItem) Set(val any) *TempItem { 122 | if ti.item == nil || ti.item.jType != JsonTypeVal { 123 | return ti 124 | } 125 | 126 | if val == nil { 127 | ti.item.val = nil 128 | return ti 129 | } 130 | 131 | switch val.(type) { 132 | case bool, string, int, float64, int64: 133 | ti.item.val = val 134 | default: 135 | } 136 | 137 | return ti 138 | } 139 | -------------------------------------------------------------------------------- /internal/util/jsons/gin.go: -------------------------------------------------------------------------------- 1 | package jsons 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // OkResp 返回 json 响应, 状态码 200 11 | func OkResp(c *gin.Context, data *Item) { 12 | Resp(c, http.StatusOK, data) 13 | } 14 | 15 | // Resp 返回 json 响应 16 | func Resp(c *gin.Context, code int, data *Item) { 17 | if data == nil { 18 | data = NewEmptyObj() 19 | } 20 | str := data.String() 21 | c.Header("Content-Length", strconv.Itoa(len(str))) 22 | c.String(code, str) 23 | } 24 | -------------------------------------------------------------------------------- /internal/util/jsons/item.go: -------------------------------------------------------------------------------- 1 | package jsons 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | "sync" 7 | ) 8 | 9 | // JsonType json 属性值类型 10 | type JsonType string 11 | 12 | const ( 13 | JsonTypeVal JsonType = "val" 14 | JsonTypeObj JsonType = "obj" 15 | JsonTypeArr JsonType = "arr" 16 | ) 17 | 18 | // ErrBreakRange 停止遍历 19 | var ErrBreakRange = errors.New("break arr or obj range") 20 | 21 | // Item 表示一个 JSON 数据项 22 | type Item struct { 23 | 24 | // val 普通值: string, bool, int, float64, 25 | val any 26 | 27 | // obj 对象值 28 | obj map[string]*Item 29 | 30 | // arr 数组值 31 | arr []*Item 32 | 33 | // jType 当前数据项类型 34 | jType JsonType 35 | 36 | // mu 并发控制 37 | mu sync.Mutex 38 | } 39 | 40 | // Type 获取 json 项类型 41 | func (i *Item) Type() JsonType { 42 | return i.jType 43 | } 44 | 45 | // Put obj 设置键值对 46 | func (i *Item) Put(key string, value *Item) { 47 | if i.jType != JsonTypeObj || value == nil { 48 | return 49 | } 50 | i.mu.Lock() 51 | defer i.mu.Unlock() 52 | i.obj[key] = value 53 | } 54 | 55 | // Attr 获取对象属性的某个 key 值 56 | func (i *Item) Attr(key string) *TempItem { 57 | ti := &TempItem{item: i} 58 | return ti.Attr(key) 59 | } 60 | 61 | // RangeObj 遍历对象 62 | func (i *Item) RangeObj(callback func(key string, value *Item) error) error { 63 | if i.jType != JsonTypeObj { 64 | return nil 65 | } 66 | for k, v := range i.obj { 67 | ck, cv := k, v 68 | if err := callback(ck, cv); err == ErrBreakRange { 69 | return nil 70 | } else if err != nil { 71 | return err 72 | } 73 | } 74 | return nil 75 | } 76 | 77 | // DelKey 删除对象中的指定键 78 | func (i *Item) DelKey(key string) { 79 | if i.jType != JsonTypeObj { 80 | return 81 | } 82 | i.mu.Lock() 83 | defer i.mu.Unlock() 84 | delete(i.obj, key) 85 | } 86 | 87 | // Append arr 添加属性 88 | func (i *Item) Append(values ...*Item) { 89 | if i.jType != JsonTypeArr || len(values) == 0 { 90 | return 91 | } 92 | i.mu.Lock() 93 | defer i.mu.Unlock() 94 | for _, value := range values { 95 | if value == nil { 96 | continue 97 | } 98 | i.arr = append(i.arr, value) 99 | } 100 | } 101 | 102 | // ValuesArr 获取数组中的所有值 103 | func (i *Item) ValuesArr() []*Item { 104 | return i.arr 105 | } 106 | 107 | // Idx 获取数组属性的指定索引值 108 | func (i *Item) Idx(index int) *TempItem { 109 | ti := &TempItem{item: i} 110 | return ti.Idx(index) 111 | } 112 | 113 | // PutIdx 设置数组指定索引的 item 114 | func (i *Item) PutIdx(index int, newItem *Item) { 115 | if i.jType != JsonTypeArr || newItem == nil { 116 | return 117 | } 118 | if index < 0 || index >= len(i.arr) { 119 | return 120 | } 121 | i.mu.Lock() 122 | defer i.mu.Unlock() 123 | i.arr[index] = newItem 124 | } 125 | 126 | // RangeArr 遍历数组 127 | func (i *Item) RangeArr(callback func(index int, value *Item) error) error { 128 | if i.jType != JsonTypeArr { 129 | return nil 130 | } 131 | for idx, v := range i.arr { 132 | ci, cv := idx, v 133 | if err := callback(ci, cv); err == ErrBreakRange { 134 | return nil 135 | } else if err != nil { 136 | return err 137 | } 138 | } 139 | return nil 140 | } 141 | 142 | // FindIdx 在数组中查找符合条件的属性的索引 143 | // 144 | // 查找不到符合条件的属性时, 返回 -1 145 | func (i *Item) FindIdx(filterFunc func(val *Item) bool) int { 146 | idx := -1 147 | if i.jType != JsonTypeArr { 148 | return idx 149 | } 150 | i.RangeArr(func(index int, value *Item) error { 151 | if filterFunc(value) { 152 | idx = index 153 | return ErrBreakRange 154 | } 155 | return nil 156 | }) 157 | return idx 158 | } 159 | 160 | // Filter 过滤数组元素, 返回一个新的 item 对象 161 | // 162 | // 如果 i 不为数组, 返回空数组 item 对象 163 | func (i *Item) Filter(filterFunc func(val *Item) bool) *Item { 164 | res := NewEmptyArr() 165 | if i.jType != JsonTypeArr { 166 | return res 167 | } 168 | i.RangeArr(func(_ int, value *Item) error { 169 | if filterFunc(value) { 170 | res.Append(value) 171 | } 172 | return nil 173 | }) 174 | return res 175 | } 176 | 177 | // Map 将数组中的元素按照指定规则映射之后返回一个新数组 178 | func (i *Item) Map(mapFunc func(val *Item) any) []any { 179 | if i.jType != JsonTypeArr { 180 | return nil 181 | } 182 | res := make([]any, 0) 183 | i.RangeArr(func(_ int, value *Item) error { 184 | res = append(res, mapFunc(value)) 185 | return nil 186 | }) 187 | return res 188 | } 189 | 190 | // Shuffle 打乱一个数组, 只有这个 item 是数组类型时, 才生效 191 | func (i *Item) Shuffle() { 192 | if i.jType != JsonTypeArr { 193 | return 194 | } 195 | i.mu.Lock() 196 | defer i.mu.Unlock() 197 | rand.Shuffle(i.Len(), func(j, k int) { 198 | i.arr[j], i.arr[k] = i.arr[k], i.arr[j] 199 | }) 200 | } 201 | 202 | // DelIdx 删除数组元素 203 | func (i *Item) DelIdx(index int) { 204 | if i.jType != JsonTypeArr { 205 | return 206 | } 207 | if index < 0 || index >= len(i.arr) { 208 | return 209 | } 210 | i.mu.Lock() 211 | defer i.mu.Unlock() 212 | i.arr = append(i.arr[:index], i.arr[index+1:]...) 213 | } 214 | 215 | // Len 返回子项个数 216 | // 217 | // 如果 Type 为 obj 和 arr, 返回子项个数 218 | // 如果 Type 为 val, 返回 0 219 | func (i *Item) Len() int { 220 | switch i.jType { 221 | case JsonTypeObj: 222 | return len(i.obj) 223 | case JsonTypeArr: 224 | return len(i.arr) 225 | default: 226 | return 0 227 | } 228 | } 229 | 230 | // Empty 返回当前项是否为空 231 | // 232 | // 如果 Type 为 obj, 值为 {}, 返回 true 233 | // 如果 Type 为 arr, 值为 [], 返回 true 234 | // 如果 Type 为 val, 值为 nil 或 "", 返回 true 235 | // 其余情况, 返回 false 236 | func (i *Item) Empty() bool { 237 | switch i.jType { 238 | case JsonTypeArr: 239 | return len(i.arr) == 0 240 | case JsonTypeObj: 241 | return len(i.obj) == 0 242 | default: 243 | if i.val == nil { 244 | return true 245 | } 246 | if v, ok := i.val.(string); ok { 247 | return v == "" 248 | } 249 | return false 250 | } 251 | } 252 | 253 | // Ti 将当前对象转换为 TempItem 对象 254 | func (i *Item) Ti() *TempItem { 255 | return &TempItem{item: i} 256 | } 257 | -------------------------------------------------------------------------------- /internal/util/jsons/jsons.go: -------------------------------------------------------------------------------- 1 | package jsons 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "reflect" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 15 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 16 | ) 17 | 18 | // NewEmptyObj 初始化一个对象类型的 json 数据 19 | func NewEmptyObj() *Item { 20 | return &Item{obj: make(map[string]*Item), jType: JsonTypeObj} 21 | } 22 | 23 | // NewEmptyArr 初始化一个数组类型的 json 数据 24 | func NewEmptyArr() *Item { 25 | return &Item{arr: make([]*Item, 0), jType: JsonTypeArr} 26 | } 27 | 28 | // NewByObj 根据对象初始化 json 数据 29 | func NewByObj(obj any) *Item { 30 | if obj == nil { 31 | return NewByVal(nil) 32 | } 33 | 34 | if item, ok := obj.(*Item); ok { 35 | return item 36 | } 37 | 38 | v := reflect.ValueOf(obj) 39 | if v.Kind() == reflect.Ptr { 40 | v = v.Elem() 41 | } 42 | if v.Kind() != reflect.Struct && v.Kind() != reflect.Map { 43 | return NewByVal(obj) 44 | } 45 | 46 | item := NewEmptyObj() 47 | wg := sync.WaitGroup{} 48 | if v.Kind() == reflect.Struct { 49 | for i := 0; i < v.NumField(); i++ { 50 | fieldVal := v.Field(i) 51 | fieldType := v.Type().Field(i) 52 | wg.Add(1) 53 | go func() { 54 | defer wg.Done() 55 | item.Put(fieldType.Name, NewByVal(fieldVal.Interface())) 56 | }() 57 | } 58 | } 59 | 60 | if v.Kind() == reflect.Map { 61 | if v.Type().Key() != reflect.TypeOf("") { 62 | panic("不支持的 map 类型") 63 | } 64 | for _, key := range v.MapKeys() { 65 | ck := key 66 | wg.Add(1) 67 | go func() { 68 | defer wg.Done() 69 | item.Put(ck.Interface().(string), NewByVal(v.MapIndex(ck).Interface())) 70 | }() 71 | } 72 | } 73 | 74 | wg.Wait() 75 | return item 76 | } 77 | 78 | // NewByArr 根据数组初始化 json 数据 79 | func NewByArr(arr any) *Item { 80 | if arr == nil { 81 | return NewByVal(nil) 82 | } 83 | 84 | if item, ok := arr.(*Item); ok { 85 | return item 86 | } 87 | 88 | v := reflect.ValueOf(arr) 89 | if v.Kind() == reflect.Ptr { 90 | v = v.Elem() 91 | } 92 | if v.Kind() != reflect.Array && v.Kind() != reflect.Slice { 93 | return NewByVal(arr) 94 | } 95 | 96 | item := &Item{arr: make([]*Item, v.Len()), jType: JsonTypeArr} 97 | wg := sync.WaitGroup{} 98 | for i := 0; i < v.Len(); i++ { 99 | ci := i 100 | wg.Add(1) 101 | go func() { 102 | defer wg.Done() 103 | item.PutIdx(ci, NewByVal(v.Index(ci).Interface())) 104 | }() 105 | } 106 | wg.Wait() 107 | return item 108 | } 109 | 110 | // NewByVal 根据指定普通值初始化 json 数据, 如果是数组或对象类型也会自动转化 111 | func NewByVal(val any) *Item { 112 | item := &Item{jType: JsonTypeVal} 113 | if val == nil { 114 | return item 115 | } 116 | 117 | if newVal, ok := val.(*Item); ok { 118 | return newVal 119 | } 120 | 121 | t := reflect.TypeOf(val) 122 | if t.Kind() == reflect.Ptr { 123 | t = t.Elem() 124 | } 125 | 126 | switch t.Kind() { 127 | case reflect.Bool, reflect.Int, reflect.Float64, reflect.Int64: 128 | item.val = val 129 | return item 130 | case reflect.String: 131 | newVal := reflect.ValueOf(val).String() 132 | if conv, err := strconv.Unquote(`"` + newVal + `"`); err == nil { 133 | // 将字符串中的 unicode 字符转换为 utf8 134 | newVal = conv 135 | } 136 | item.val = urls.TransferSlash(newVal) 137 | return item 138 | case reflect.Struct, reflect.Map: 139 | return NewByObj(val) 140 | case reflect.Array, reflect.Slice: 141 | return NewByArr(val) 142 | default: 143 | log.Panicf("无效的数据类型, kind: %v, name: %v", t.Kind(), t.Name()) 144 | return nil 145 | } 146 | } 147 | 148 | // New 从 json 字符串中初始化成 item 对象 149 | func New(rawJson string) (*Item, error) { 150 | if strs.AnyEmpty(rawJson) { 151 | return NewByVal(rawJson), nil 152 | } 153 | 154 | if strings.HasPrefix(rawJson, `"`) && strings.HasSuffix(rawJson, `"`) { 155 | return NewByVal(rawJson[1 : len(rawJson)-1]), nil 156 | } 157 | 158 | if rawJson == "null" { 159 | return NewByVal(nil), nil 160 | } 161 | 162 | if strings.HasPrefix(rawJson, "{") { 163 | var data map[string]json.RawMessage 164 | if err := json.Unmarshal([]byte(rawJson), &data); err != nil { 165 | return nil, err 166 | } 167 | 168 | item := NewEmptyObj() 169 | wg := sync.WaitGroup{} 170 | var handleErr error 171 | for key, value := range data { 172 | ck, cv := key, value 173 | wg.Add(1) 174 | go func() { 175 | defer wg.Done() 176 | subI, err := New(string(cv)) 177 | if err != nil { 178 | handleErr = err 179 | return 180 | } 181 | item.Put(ck, subI) 182 | }() 183 | } 184 | wg.Wait() 185 | 186 | if handleErr != nil { 187 | return nil, handleErr 188 | } 189 | return item, nil 190 | } 191 | 192 | if strings.HasPrefix(rawJson, "[") { 193 | var data []json.RawMessage 194 | if err := json.Unmarshal([]byte(rawJson), &data); err != nil { 195 | return nil, err 196 | } 197 | 198 | item := &Item{arr: make([]*Item, len(data)), jType: JsonTypeArr} 199 | wg := sync.WaitGroup{} 200 | var handleErr error 201 | for idx, value := range data { 202 | ci, cv := idx, value 203 | wg.Add(1) 204 | go func() { 205 | defer wg.Done() 206 | subI, err := New(string(cv)) 207 | if err != nil { 208 | handleErr = err 209 | return 210 | } 211 | item.PutIdx(ci, subI) 212 | }() 213 | } 214 | wg.Wait() 215 | 216 | if handleErr != nil { 217 | return nil, handleErr 218 | } 219 | return item, nil 220 | } 221 | 222 | // 尝试转换成基础类型 223 | var b bool 224 | if err := json.Unmarshal([]byte(rawJson), &b); err == nil { 225 | return NewByVal(b), nil 226 | } 227 | var i int 228 | if err := json.Unmarshal([]byte(rawJson), &i); err == nil { 229 | return NewByVal(i), nil 230 | } 231 | var i64 int64 232 | if err := json.Unmarshal([]byte(rawJson), &i64); err == nil { 233 | return NewByVal(i64), nil 234 | } 235 | var f float64 236 | if err := json.Unmarshal([]byte(rawJson), &f); err == nil { 237 | return NewByVal(f), nil 238 | } 239 | 240 | return nil, fmt.Errorf("不支持的字符串: %s", rawJson) 241 | } 242 | 243 | // Read 从流中读取 JSON 数据并转换为对象 244 | func Read(reader io.Reader) (*Item, error) { 245 | if reader == nil { 246 | return nil, errors.New("reader 为空") 247 | } 248 | 249 | bytes, err := io.ReadAll(reader) 250 | if err != nil { 251 | return nil, fmt.Errorf("读取 reader 数据失败: %v", err) 252 | } 253 | return New(string(bytes)) 254 | } 255 | -------------------------------------------------------------------------------- /internal/util/jsons/jsons_test.go: -------------------------------------------------------------------------------- 1 | package jsons_test 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 9 | ) 10 | 11 | func TestMarshal(t *testing.T) { 12 | log.Println(jsons.NewByVal("Ambitious")) 13 | log.Println(jsons.NewByVal(true)) 14 | log.Println(jsons.NewByVal(23)) 15 | log.Println(jsons.NewByVal(3.14159)) 16 | log.Println(jsons.NewByVal(nil)) 17 | 18 | arr := []any{"Ambitious", true, 23, 3.14159, nil} 19 | log.Println(jsons.NewByArr(arr)) 20 | 21 | m := map[string]any{"1": arr} 22 | log.Println(jsons.NewByObj(m)) 23 | 24 | arr = append(arr, map[string]any{"Path": "/a/b/c", "Age": 18, "Name": nil}) 25 | log.Println(jsons.NewByArr(arr)) 26 | } 27 | 28 | func TestUnmarshal(t *testing.T) { 29 | str := `{"Test": null,"MediaSources":[{"Size":1297828216,"SupportsDirectPlay":false,"TranscodingContainer":"ts","Container":"mp4","Name":"(SD_720x480) 1080p HEVC","Protocol":"File","Formats":[],"TranscodingSubProtocol":"hls","SupportsProbing":false,"HasMixedProtocols":false,"RequiresLooping":false,"Id":"bd083e9d70f3b7322f43fcab2a3dea13","Path":"/data/show/Y/院人全年无休计划 (2023)/Season 2/No.Days.Off.All.Year.S02E01.2024.WEB-DL.1080p.H265.AAC-01案:沉默的三面羊Ⅰ(上).mp4","SupportsDirectStream":false,"RequiresClosing":false,"Bitrate":2270287,"RequiredHttpHeaders":{},"AddApiKeyToDirectStreamUrl":false,"DefaultAudioStreamIndex":1,"IsRemote":false,"SupportsTranscoding":true,"ReadAtNativeFramerate":false,"TranscodingUrl":"/videos/4005/stream?IsPlayback=false&MaxStreamingBitrate=7000000&X-Emby-Client-Version=4.7.13.0&reqformat=json&MediaSourceId=bd083e9d70f3b7322f43fcab2a3dea13&AutoOpenLiveStream=false&X-Emby-Token=20a4b90f62bc43d9a189a36c71784d7b&StartTimeTicks=0&X-Emby-Device-Id=TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzExNC4wLjAuMCBTYWZhcmkvNTM3LjM2fDE2ODkxNDkxNjU5NzA1&X-Emby-Client=Emby%20Web&UserId=607606506bab49829edc8e45873f374f&X-Emby-Device-Name=Chrome%20macOS&X-Emby-Language=zh-cn&Static=true&video_preview_format=SD","Type":"Default","IsInfiniteStream":false,"ItemId":"4005","MediaStreams":[{"RealFrameRate":25,"ColorPrimaries":"bt709","IsExternal":false,"ColorTransfer":"bt709","Width":1920,"Protocol":"File","Level":120,"IsAnamorphic":false,"IsInterlaced":false,"IsDefault":true,"AspectRatio":"16:9","TimeBase":"1/90000","IsForced":false,"Language":"und","BitDepth":8,"BitRate":2073001,"SupportsExternalStream":false,"CodecTag":"hev1","AverageFrameRate":25,"ExtendedVideoSubTypeDescription":"None","AttachmentSize":0,"RefFrames":1,"Height":1080,"IsTextSubtitleStream":false,"Codec":"hevc","VideoRange":"SDR","Index":0,"ExtendedVideoType":"None","ColorSpace":"bt709","Type":"Video","IsHearingImpaired":false,"Profile":"Main","PixelFormat":"yuv420p","DisplayTitle":"1080p HEVC","ExtendedVideoSubType":"None"},{"SampleRate":44100,"IsExternal":false,"Protocol":"File","IsInterlaced":false,"IsDefault":true,"TimeBase":"1/44100","Channels":2,"IsForced":false,"Language":"und","BitRate":189588,"SupportsExternalStream":false,"CodecTag":"mp4a","ExtendedVideoSubTypeDescription":"None","AttachmentSize":0,"IsTextSubtitleStream":false,"Codec":"aac","Index":1,"ExtendedVideoType":"None","ChannelLayout":"stereo","Type":"Audio","IsHearingImpaired":false,"Profile":"LC","DisplayTitle":"AAC stereo (默认)","ExtendedVideoSubType":"None"}],"RunTimeTicks":45732640000,"RequiresOpening":false}],"PlaySessionId":"51dcf8dadc4d4b81b52613b88cd4e93f"}` 30 | item, err := jsons.New(str) 31 | if err != nil { 32 | t.Fatal(err) 33 | return 34 | } 35 | log.Println("反序列化成功") 36 | 37 | log.Println("当前 json 类型: ", item.Type()) 38 | item.Attr("Test").Set("This val has modified by test program") 39 | 40 | item.Attr("MediaSources").Idx(0).Attr("DefaultAudioStreamIndex").Set("😁") 41 | 42 | log.Println("重新序列化: ", item.String()) 43 | } 44 | 45 | func TestMap(t *testing.T) { 46 | item := jsons.NewByArr([]any{1, 2, 1, 3, 8}) 47 | res := item.Map(func(val *jsons.Item) any { return "😄" + strconv.Itoa(val.Ti().Val().(int)) }) 48 | log.Println("转换完成后的数组: ", res) 49 | } 50 | -------------------------------------------------------------------------------- /internal/util/jsons/serialize.go: -------------------------------------------------------------------------------- 1 | package jsons 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | // Struct 将 item 转换为结构体对象 11 | func (i *Item) Struct() any { 12 | switch i.jType { 13 | case JsonTypeVal: 14 | return i.val 15 | case JsonTypeObj: 16 | m := make(map[string]any) 17 | wg := sync.WaitGroup{} 18 | mu := sync.Mutex{} 19 | for key, value := range i.obj { 20 | ck, cv := key, value 21 | wg.Add(1) 22 | go func() { 23 | defer wg.Done() 24 | mu.Lock() 25 | defer mu.Unlock() 26 | m[ck] = cv.Struct() 27 | }() 28 | } 29 | wg.Wait() 30 | return m 31 | case JsonTypeArr: 32 | a := make([]any, i.Len()) 33 | wg := sync.WaitGroup{} 34 | for idx, value := range i.arr { 35 | ci, cv := idx, value 36 | wg.Add(1) 37 | go func() { 38 | defer wg.Done() 39 | a[ci] = cv.Struct() 40 | }() 41 | } 42 | wg.Wait() 43 | return a 44 | default: 45 | return "Error jType" 46 | } 47 | } 48 | 49 | // String 将 item 转换为 json 字符串 50 | func (i *Item) String() string { 51 | switch i.jType { 52 | case JsonTypeVal: 53 | if i.val == nil { 54 | return "null" 55 | } 56 | 57 | bytes, _ := json.Marshal(i.val) 58 | str := string(bytes) 59 | return str 60 | case JsonTypeObj: 61 | sb := strings.Builder{} 62 | sb.WriteString("{") 63 | cur, tot := 0, len(i.obj) 64 | for key, value := range i.obj { 65 | sb.WriteString(fmt.Sprintf(`"%s":%s`, key, value.String())) 66 | cur++ 67 | if cur != tot { 68 | sb.WriteString(",") 69 | } 70 | } 71 | sb.WriteString("}") 72 | return sb.String() 73 | case JsonTypeArr: 74 | sb := strings.Builder{} 75 | sb.WriteString("[") 76 | for idx, value := range i.arr { 77 | sb.WriteString(value.String()) 78 | if idx < len(i.arr)-1 { 79 | sb.WriteString(",") 80 | } 81 | } 82 | sb.WriteString("]") 83 | return sb.String() 84 | default: 85 | return "Error jType" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/util/maps/maps.go: -------------------------------------------------------------------------------- 1 | package maps 2 | 3 | // Keys 返回一个 map 的所有 key 组成的切片 4 | func Keys[K comparable, V any](m map[K]V) []K { 5 | res := make([]K, 0) 6 | if m == nil { 7 | return res 8 | } 9 | 10 | for k := range m { 11 | res = append(res, k) 12 | } 13 | 14 | return res 15 | } 16 | -------------------------------------------------------------------------------- /internal/util/randoms/randoms.go: -------------------------------------------------------------------------------- 1 | package randoms 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | ) 7 | 8 | // hexs 16 进制字符 9 | var hexs = []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"} 10 | 11 | // RandomHex 随机返回一串 16 进制的字符串, 可通过 n 指定长度 12 | func RandomHex(n int) string { 13 | if n <= 0 { 14 | return "" 15 | } 16 | sb := strings.Builder{} 17 | for n > 0 { 18 | idx := rand.Intn(len(hexs)) 19 | sb.WriteString(hexs[idx]) 20 | n-- 21 | } 22 | return sb.String() 23 | } 24 | -------------------------------------------------------------------------------- /internal/util/slices/slices.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | // Copy 拷贝切片 4 | func Copy[T any](src []T) []T { 5 | if len(src) == 0 { 6 | return []T{} 7 | } 8 | return append(([]T)(nil), src...) 9 | } 10 | -------------------------------------------------------------------------------- /internal/util/strs/strs.go: -------------------------------------------------------------------------------- 1 | package strs 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | // AllNotEmpty 所有字符串都不为空时, 返回 true 9 | func AllNotEmpty(strs ...string) bool { 10 | return !AnyEmpty(strs...) 11 | } 12 | 13 | // AnyEmpty 有任意一个字符串为空时, 返回 true 14 | func AnyEmpty(strs ...string) bool { 15 | for _, str := range strs { 16 | if str = strings.TrimSpace(str); str == "" { 17 | return true 18 | } 19 | } 20 | return false 21 | } 22 | 23 | // Sort 将一个字符串进行字典序排序 24 | func Sort(str string) string { 25 | runes := []rune(str) 26 | sort.SliceStable(runes, func(i, j int) bool { 27 | return runes[i] < runes[j] 28 | }) 29 | return string(runes) 30 | } 31 | -------------------------------------------------------------------------------- /internal/util/structs/structs.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | // String 将结构体转换为可读的字符串 10 | // 11 | // @param s 12 | // @return string 13 | func String(s any) string { 14 | if !IsStruct(s) { 15 | return fmt.Sprintf("%v", s) 16 | } 17 | t := reflect.TypeOf(s) 18 | v := reflect.ValueOf(s) 19 | if t.Kind() == reflect.Ptr { 20 | t, v = t.Elem(), v.Elem() 21 | } 22 | sb := strings.Builder{} 23 | sb.WriteString("{") 24 | for i := 0; i < t.NumField(); i++ { 25 | fieldType := t.Field(i) 26 | val := v.Field(i) 27 | if IsStruct(val.Interface()) { 28 | sb.WriteString(fmt.Sprintf("%s: %s", fieldType.Name, String(val.Interface()))) 29 | } else { 30 | sb.WriteString(fmt.Sprintf("%s: %v", fieldType.Name, val.Interface())) 31 | } 32 | if i < t.NumField()-1 { 33 | sb.WriteString(", ") 34 | } 35 | } 36 | sb.WriteString("}") 37 | return sb.String() 38 | } 39 | 40 | // IsStruct 判断一个变量是不是结构体 41 | // 42 | // @param v 43 | // @return bool 44 | func IsStruct(v any) bool { 45 | if v == nil { 46 | return false 47 | } 48 | t := reflect.TypeOf(v) 49 | if t.Kind() == reflect.Ptr { 50 | t = t.Elem() 51 | } 52 | return t.Kind() == reflect.Struct 53 | } 54 | -------------------------------------------------------------------------------- /internal/util/urls/urls.go: -------------------------------------------------------------------------------- 1 | package urls 2 | 3 | import ( 4 | "log" 5 | "net/url" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 10 | ) 11 | 12 | // IsRemote 检查一个地址是否是远程地址 13 | func IsRemote(path string) bool { 14 | u, err := url.Parse(path) 15 | if err != nil { 16 | return false 17 | } 18 | return u.Host != "" 19 | } 20 | 21 | // TransferSlash 将传递的路径的斜杠转换为正斜杠 22 | // 23 | // 如果传递的参数不是一个路径, 不作任何处理 24 | func TransferSlash(p string) string { 25 | if strs.AnyEmpty(p) { 26 | return p 27 | } 28 | _, err := url.Parse(p) 29 | if err != nil { 30 | return p 31 | } 32 | return strings.ReplaceAll(p, `\`, `/`) 33 | } 34 | 35 | // ResolveResourceName 解析一个资源 url 的名称 36 | // 37 | // 比如 http://example.com/a.txt?a=1&b=2 会返回 a.txt 38 | func ResolveResourceName(resUrl string) string { 39 | u, err := url.Parse(resUrl) 40 | if err != nil { 41 | return resUrl 42 | } 43 | return filepath.Base(u.Path) 44 | } 45 | 46 | // ReplaceAll 类似于 strings.ReplaceAll 47 | // 48 | // 区别在于可以一次性传入多个子串进行替换 49 | func ReplaceAll(rawUrl string, oldNews ...string) string { 50 | if len(oldNews) < 2 { 51 | return rawUrl 52 | } 53 | for i := 0; i < len(oldNews)-1; i += 2 { 54 | rawUrl = strings.ReplaceAll(rawUrl, oldNews[i], oldNews[i+1]) 55 | } 56 | return rawUrl 57 | } 58 | 59 | // AppendArgs 往 url 中添加 query 参数 60 | // 61 | // 添加参数按照键值对的顺序依次传递到函数中, 62 | // 仅出现偶数个参数才会成功匹配出一个 query 参数 63 | // 64 | // 如果在拼接的过程中出现任何异常, 会返回 rawUrl 而不作任何修改 65 | func AppendArgs(rawUrl string, kvs ...string) string { 66 | if len(kvs) < 2 { 67 | return rawUrl 68 | } 69 | 70 | u, err := url.Parse(rawUrl) 71 | if err != nil { 72 | log.Printf("AppendUrlArgs 转换 rawUrl 时出现异常: %v", err) 73 | return rawUrl 74 | } 75 | 76 | q := u.Query() 77 | for i := 0; i < len(kvs)-1; i += 2 { 78 | q.Set(kvs[i], kvs[i+1]) 79 | } 80 | u.RawQuery = q.Encode() 81 | return u.String() 82 | } 83 | -------------------------------------------------------------------------------- /internal/util/urls/urls_test.go: -------------------------------------------------------------------------------- 1 | package urls_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "testing" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 9 | ) 10 | 11 | func TestResolveResourceName(t *testing.T) { 12 | resUrl := `https://ccp-bj29-video-preview.oss-enet.aliyuncs.com/lt/34860EF2932C94202A63D4F29C188097043205F9_1300259473__sha1_bj29/subtitle/subtitle_3.vtt?di=bj29&dr=339781490&f=64617b3bae2de1e1a8df40898a3952be207aca70&pds-params=%7B%22ap%22%3A%2276917ccccd4441c39457a04f6084fb2f%22%7D&security-token=CAISvgJ1q6Ft5B2yfSjIr5fdCNPapqtx8qqBRFT3pzZnerYegf3uoDz2IHhMf3NpBOkZvvQ1lGlU6%2Fcalq5rR4QAXlDfNQOaZ3ueq1HPWZHInuDox55m4cTXNAr%2BIhr%2F29CoEIedZdjBe%2FCrRknZnytou9XTfimjWFrXWv%2Fgy%2BQQDLItUxK%2FcCBNCfpPOwJms7V6D3bKMuu3OROY6Qi5TmgQ41Uh1jgjtPzkkpfFtkGF1GeXkLFF%2B97DRbG%2FdNRpMZtFVNO44fd7bKKp0lQLs0ARrv4r1fMUqW2X543AUgFLhy2KKMPY99xpFgh9a7j0iCbSGyUu%2FhcRm5sw9%2Byfo34lVYneY7xZ%2ByHN7uHwufJ7FxfIREfquk63pvSlHLcLPe0Kjzzleo2k1XRPVFF%2B535IaHXuToXDnvSi14GOAfXtuMkagAFOD20a2BT1Wf4wXbyRcR0HqWAtw6i4kBO%2FKsslS04SG6AUnRimmPPJrKlvqjGheg3hUwe%2Bky9jH8AJ2d9zU0Og9msrSSOY%2FEgqydcHEFhYcwDhXIQbA7Iyt18mqoFDkBrYwe0NSB5bm%2BlDCUbi2L68sXFkAD7HKKS1Z%2FKCFYrn9SAA&u=6780dc8ea26d48ac88981c851052d77c&x-oss-access-key-id=STS.NThCinKtPEhjFrFC62v92n8EB&x-oss-expires=1726738969&x-oss-signature=DWIp%2FJW0CPl4kennw6n6yAaKVCGrazUBXE5qho%2Ba8xk%3D&x-oss-signature-version=OSS2` 13 | fmt.Printf("urls.ResolveResourceName(resUrl): %v\n", urls.ResolveResourceName(resUrl)) 14 | } 15 | 16 | func TestAppendUrlArgs(t *testing.T) { 17 | rawUrl := "http://localhost:8095/emby/Items/2008/PlaybackInfo?reqformat=json" 18 | res := urls.AppendArgs(rawUrl, "ambitious", "jun", "Static", "true", "unvalid") 19 | log.Println("拼接后的结果: ", res) 20 | } 21 | 22 | func TestIsRemote(t *testing.T) { 23 | type args struct { 24 | path string 25 | } 26 | tests := []struct { 27 | name string 28 | args args 29 | want bool 30 | }{ 31 | {name: "rtp", args: args{path: "rtp://1.2.3.4:9999"}, want: true}, 32 | {name: "http", args: args{path: "http://localhost:8095/emby/videos/53507/stream"}, want: true}, 33 | {name: "https", args: args{path: "https://localhost:8095/emby/videos/53507/stream"}, want: true}, 34 | {name: "file-unix", args: args{path: "/usr/local/app/test.mp4"}, want: false}, 35 | {name: "file-windows", args: args{path: `D:\user\local\app\test.mp4`}, want: false}, 36 | } 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | if got := urls.IsRemote(tt.args.path); got != tt.want { 40 | t.Errorf("IsRemote() = %v, want %v", got, tt.want) 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/web/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 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/encrypts" 15 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 16 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 17 | "github.com/AmbitiousJun/go-emby2alist/internal/util/urls" 18 | 19 | "github.com/gin-gonic/gin" 20 | ) 21 | 22 | // CacheKeyIgnoreParams 忽略的请求头或者参数 23 | // 24 | // 如果请求地址包含列表中的请求头或者参数, 则不参与 cacheKey 运算 25 | var CacheKeyIgnoreParams = map[string]struct{}{ 26 | // Fileball 27 | "StartTimeTicks": {}, "X-Playback-Session-Id": {}, 28 | 29 | // Emby 30 | "PlaySessionId": {}, 31 | 32 | // Common 33 | "Range": {}, "Host": {}, "Referrer": {}, "Connection": {}, 34 | "Accept": {}, "Accept-Encoding": {}, "Accept-Language": {}, "Cache-Control": {}, 35 | "Upgrade-Insecure-Requests": {}, "Referer": {}, "Origin": {}, 36 | 37 | // StreamMusic 38 | "X-Streammusic-Audioid": {}, "X-Streammusic-Savepath": {}, 39 | 40 | // IP 41 | "X-Forwarded-For": {}, "X-Real-IP": {}, "Forwarded": {}, "Client-IP": {}, 42 | "True-Client-IP": {}, "CF-Connecting-IP": {}, "X-Cluster-Client-IP": {}, 43 | "Fastly-Client-IP": {}, "X-Client-IP": {}, "X-ProxyUser-IP": {}, 44 | "Via": {}, "Forwarded-For": {}, "X-From-Cdn": {}, 45 | } 46 | 47 | // CacheableRouteMarker 缓存白名单 48 | // 只有匹配上正则表达式的路由才会被缓存 49 | func CacheableRouteMarker() gin.HandlerFunc { 50 | cacheablePatterns := []*regexp.Regexp{ 51 | regexp.MustCompile(constant.Reg_PlaybackInfo), 52 | regexp.MustCompile(constant.Reg_VideoSubtitles), 53 | regexp.MustCompile(constant.Reg_ResourceStream), 54 | regexp.MustCompile(constant.Reg_ItemDownload), 55 | regexp.MustCompile(constant.Reg_ItemSyncDownload), 56 | regexp.MustCompile(constant.Reg_UserItemsRandomWithLimit), 57 | } 58 | 59 | return func(c *gin.Context) { 60 | for _, pattern := range cacheablePatterns { 61 | if pattern.MatchString(c.Request.RequestURI) { 62 | return 63 | } 64 | } 65 | c.Header(HeaderKeyExpired, "-1") 66 | } 67 | } 68 | 69 | // RequestCacher 请求缓存中间件 70 | func RequestCacher() gin.HandlerFunc { 71 | return func(c *gin.Context) { 72 | // 1 判断请求是否需要缓存 73 | if c.Writer.Header().Get(HeaderKeyExpired) == "-1" { 74 | return 75 | } 76 | 77 | // 2 计算 cache key 78 | cacheKey, err := calcCacheKey(c) 79 | if err != nil { 80 | log.Printf("cache key 计算异常: %v, 跳过缓存", err) 81 | // 如果没有调用 Abort, Gin 会自动继续调用处理器链 82 | return 83 | } 84 | 85 | // 3 尝试获取缓存 86 | if rc, ok := getCache(cacheKey); ok { 87 | if https.IsRedirectCode(rc.code) { 88 | // 适配重定向请求 89 | c.Redirect(rc.code, rc.header.header.Get("Location")) 90 | } else { 91 | c.Status(rc.code) 92 | https.CloneHeader(c, rc.header.header) 93 | c.Writer.Write(rc.body) 94 | } 95 | c.Abort() 96 | return 97 | } 98 | 99 | // 4 使用自定义的响应器 100 | customWriter := &respCacheWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer} 101 | c.Writer = customWriter 102 | 103 | // 5 执行请求处理器 104 | c.Next() 105 | 106 | // 6 不缓存错误请求 107 | if https.IsErrorResponse(c) { 108 | return 109 | } 110 | 111 | // 7 刷新缓存 112 | header := c.Writer.Header() 113 | respHeader := respHeader{ 114 | expired: header.Get(HeaderKeyExpired), 115 | space: header.Get(HeaderKeySpace), 116 | spaceKey: header.Get(HeaderKeySpaceKey), 117 | header: header.Clone(), 118 | } 119 | defer header.Del(HeaderKeyExpired) 120 | defer header.Del(HeaderKeySpace) 121 | defer header.Del(HeaderKeySpaceKey) 122 | 123 | go putCache(cacheKey, c, customWriter.body, respHeader) 124 | } 125 | } 126 | 127 | // Duration 将一个标准的时间转换成适用于缓存时间的字符串 128 | func Duration(d time.Duration) string { 129 | expired := d.Milliseconds() + time.Now().UnixMilli() 130 | return fmt.Sprintf("%v", expired) 131 | } 132 | 133 | // WaitingForHandleChan 等待预缓存通道被处理完毕 134 | func WaitingForHandleChan() { 135 | cacheHandleWaitGroup.Wait() 136 | } 137 | 138 | // calcCacheKey 计算缓存 key 139 | // 140 | // 计算方式: 取出 请求方法, 请求路径, 请求体, 请求头 转换成字符串之后字典排序, 141 | // 再进行 Md5Hash 142 | func calcCacheKey(c *gin.Context) (string, error) { 143 | method := c.Request.Method 144 | 145 | q := c.Request.URL.Query() 146 | for key := range CacheKeyIgnoreParams { 147 | q.Del(key) 148 | } 149 | c.Request.URL.RawQuery = q.Encode() 150 | uri := c.Request.URL.String() 151 | 152 | body := "" 153 | if c.Request.Body != nil { 154 | bodyBytes, err := io.ReadAll(c.Request.Body) 155 | if err != nil { 156 | return "", fmt.Errorf("读取请求体失败: %v", err) 157 | } 158 | body = string(bodyBytes) 159 | c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 160 | } 161 | header := strings.Builder{} 162 | for key, values := range c.Request.Header { 163 | if _, ok := CacheKeyIgnoreParams[key]; ok { 164 | continue 165 | } 166 | header.WriteString(key) 167 | header.WriteString("=") 168 | header.WriteString(strings.Join(values, "|")) 169 | header.WriteString(";") 170 | } 171 | 172 | headerStr := header.String() 173 | preEnc := strs.Sort(c.Request.URL.RawQuery + body + headerStr) 174 | if headerStr != "" { 175 | log.Println("headers to encode cacheKey: ", colors.ToYellow(headerStr)) 176 | } 177 | 178 | // 为防止字典排序后, 不同的 uri 冲突, 这里在排序完的字符串前再加上原始的 uri 179 | uriNoArgs := urls.ReplaceAll( 180 | uri, 181 | "?"+c.Request.URL.RawQuery, "", 182 | c.Request.URL.RawQuery, "", 183 | ) 184 | 185 | hash := encrypts.Md5Hash(method + uriNoArgs + preEnc) 186 | return hash, nil 187 | } 188 | -------------------------------------------------------------------------------- /internal/web/cache/holder.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "strconv" 7 | "sync" 8 | "time" 9 | 10 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 11 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 12 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 13 | 14 | "github.com/gin-gonic/gin" 15 | ) 16 | 17 | const ( 18 | 19 | // MaxCacheSize 缓存最大大小 (Byte) 20 | // 21 | // 这里的大小指的是响应体大小, 实际占用大小可能略大一些 22 | MaxCacheSize int64 = 100 * 1024 * 1024 23 | 24 | // MaxCacheNum 最多缓存多少个请求信息 25 | MaxCacheNum = 8092 26 | 27 | // HeaderKeyExpired 缓存过期响应头, 用于覆盖默认的缓存过期时间 28 | HeaderKeyExpired = "Expired" 29 | ) 30 | 31 | // currentCacheSize 当前内存中的缓存大小 (Byte) 32 | var currentCacheSize int64 = 0 33 | 34 | // DefaultExpired 默认的请求过期时间 35 | // 36 | // 可通过设置 "Expired" 响应头进行覆盖 37 | var DefaultExpired = func() time.Duration { return config.C.Cache.ExpiredDuration() } 38 | 39 | // cacheMap 存放缓存数据的 map 40 | var cacheMap = sync.Map{} 41 | 42 | // preCacheChan 预缓存通道 43 | // 44 | // 缓存数据先暂存在通道中, 再由专门的 goroutine 单线程处理 45 | // 46 | // preCacheChan 的淘汰规则是先入先淘汰, 不管缓存对象的过期时间 47 | var preCacheChan = make(chan *respCache, MaxCacheNum) 48 | 49 | // cacheHandleWaitGroup 允许等待预缓存通道处理完毕后再获取数据 50 | var cacheHandleWaitGroup = sync.WaitGroup{} 51 | 52 | func init() { 53 | go loopMaintainCache() 54 | } 55 | 56 | // loopMaintainCache cacheMap 由单独的 goroutine 维护 57 | func loopMaintainCache() { 58 | 59 | // cleanCache 清洗缓存数据 60 | cleanCache := func() { 61 | validCnt := 0 62 | nowMillis := time.Now().UnixMilli() 63 | toDelete := make([]*respCache, 0) 64 | 65 | cacheMap.Range(func(key, value any) bool { 66 | rc := value.(*respCache) 67 | if nowMillis > rc.expired || validCnt == MaxCacheNum || currentCacheSize > MaxCacheSize { 68 | toDelete = append(toDelete, rc) 69 | } else { 70 | validCnt++ 71 | } 72 | return true 73 | }) 74 | 75 | for _, rc := range toDelete { 76 | cacheMap.Delete(rc.cacheKey) 77 | currentCacheSize -= int64(len(rc.body)) 78 | delSpaceCache(rc.header.space, rc.header.spaceKey) 79 | } 80 | } 81 | 82 | // putrespCache 将缓存对象维护到 cacheMap 中 83 | // 84 | // 同时淘汰掉过期缓存 85 | putrespCache := func(rc *respCache) { 86 | cacheMap.Store(rc.cacheKey, rc) 87 | currentCacheSize += int64(len(rc.body)) 88 | space, spaceKey := rc.header.space, rc.header.spaceKey 89 | if strs.AllNotEmpty(space, spaceKey) { 90 | putSpaceCache(space, spaceKey, rc) 91 | log.Printf(colors.ToGreen("刷新缓存空间, space: %s, spaceKey: %s"), space, spaceKey) 92 | } 93 | } 94 | 95 | timer := time.NewTicker(time.Second * 10) 96 | defer timer.Stop() 97 | for { 98 | select { 99 | case rc := <-preCacheChan: 100 | putrespCache(rc) 101 | cacheHandleWaitGroup.Done() 102 | case <-timer.C: 103 | cleanCache() 104 | } 105 | } 106 | } 107 | 108 | // getCache 根据 cacheKey 获取缓存 109 | func getCache(cacheKey string) (*respCache, bool) { 110 | if c, ok := cacheMap.Load(cacheKey); ok { 111 | return c.(*respCache), true 112 | } 113 | return nil, false 114 | } 115 | 116 | // putCache 设置缓存 117 | func putCache(cacheKey string, c *gin.Context, respBody *bytes.Buffer, respHeader respHeader) { 118 | if cacheKey == "" || c == nil || respBody == nil { 119 | return 120 | } 121 | 122 | // 计算缓存过期时间 123 | nowMillis := time.Now().UnixMilli() 124 | expiredMillis := int64(DefaultExpired()) + nowMillis 125 | if expiredNum, err := strconv.Atoi(respHeader.expired); err == nil { 126 | customMillis := int64(expiredNum) 127 | 128 | // 特定接口不使用缓存 129 | if customMillis < 0 { 130 | return 131 | } 132 | 133 | if customMillis > nowMillis { 134 | expiredMillis = customMillis 135 | } 136 | } 137 | 138 | rc := &respCache{ 139 | code: c.Writer.Status(), 140 | body: respBody.Bytes(), 141 | cacheKey: cacheKey, 142 | expired: expiredMillis, 143 | header: respHeader, 144 | } 145 | 146 | // 依据先进先淘汰原则, 将最新缓存放入预缓存通道中 147 | cacheHandleWaitGroup.Add(1) 148 | doneOnce := sync.OnceFunc(cacheHandleWaitGroup.Done) 149 | for { 150 | select { 151 | case preCacheChan <- rc: 152 | return 153 | default: 154 | log.Println("预缓存通道已满, 淘汰旧缓存") 155 | <-preCacheChan 156 | doneOnce() 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /internal/web/cache/public.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | 7 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 8 | ) 9 | 10 | // RespCache 公开对外暴露的缓存接口 11 | type RespCache interface { 12 | // Code 响应码 13 | Code() int 14 | 15 | // Body 克隆一个响应体, 转换为缓冲区 16 | Body() *bytes.Buffer 17 | 18 | // BodyBytes 克隆一个响应体 19 | BodyBytes() []byte 20 | 21 | // JsonBody 将响应体转化成 json 返回 22 | JsonBody() (*jsons.Item, error) 23 | 24 | // Header 获取响应头属性 25 | Header(key string) string 26 | 27 | // Headers 获取克隆响应头 28 | Headers() http.Header 29 | 30 | // Space 获取缓存空间名称 31 | Space() string 32 | 33 | // SpaceKey 获取缓存空间 key 34 | SpaceKey() string 35 | 36 | // Update 更新缓存 37 | // 38 | // code 传递零值时, 会自动忽略更新 39 | // 40 | // body 传递 nil 时, 会自动忽略更新, 41 | // 传递空切片时, 会认为是一个空响应体进行更新 42 | // 43 | // header 传递 nil 时, 会自动忽略更新, 44 | // 不为 nil 时, 缓存的响应头会被清空, 并设置为新值 45 | Update(code int, body []byte, header http.Header) 46 | } 47 | -------------------------------------------------------------------------------- /internal/web/cache/space.go: -------------------------------------------------------------------------------- 1 | // 缓存空间功能, 将特定请求的响应缓存分类整理好 2 | // 便于后续其他请求复用响应 3 | package cache 4 | 5 | import ( 6 | "sync" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/strs" 9 | ) 10 | 11 | const ( 12 | 13 | // HeaderKeySpace 缓存空间 key 14 | HeaderKeySpace = "Space" 15 | 16 | // HeaderKeySpaceKey 缓存空间内部 key 17 | HeaderKeySpaceKey = "Space-Key" 18 | ) 19 | 20 | // spaceMap 缓存空间 21 | // 22 | // 三层结构: map[string]map[string]*respCache 23 | var spaceMap = sync.Map{} 24 | 25 | // GetSpaceCache 获取缓存空间的缓存对象 26 | func GetSpaceCache(space, spaceKey string) (RespCache, bool) { 27 | if strs.AnyEmpty(space, spaceKey) { 28 | return nil, false 29 | } 30 | s := getSpace(space) 31 | rc, ok := getSpaceCache(s, spaceKey) 32 | if !ok { 33 | return nil, false 34 | } 35 | return rc, true 36 | } 37 | 38 | // putSpaceCache 设置缓存到缓存空间中 39 | func putSpaceCache(space, spaceKey string, cache *respCache) { 40 | if strs.AnyEmpty(space, spaceKey) { 41 | return 42 | } 43 | getSpace(space).Store(spaceKey, cache) 44 | } 45 | 46 | func delSpaceCache(space, spaceKey string) { 47 | if strs.AnyEmpty(space, spaceKey) { 48 | return 49 | } 50 | getSpace(space).Delete(spaceKey) 51 | } 52 | 53 | // getSpace 获取缓存空间 54 | // 55 | // 不存在指定名称的空间时, 初始化一个新的空间 56 | func getSpace(space string) *sync.Map { 57 | if strs.AnyEmpty(space) { 58 | return nil 59 | } 60 | s, _ := spaceMap.LoadOrStore(space, new(sync.Map)) 61 | return s.(*sync.Map) 62 | } 63 | 64 | // getSpaceCache 获取缓存空间中的某个缓存 65 | func getSpaceCache(space *sync.Map, spaceKey string) (*respCache, bool) { 66 | if space == nil || strs.AnyEmpty(spaceKey) { 67 | return nil, false 68 | } 69 | if cache, ok := space.Load(spaceKey); ok { 70 | return cache.(*respCache), true 71 | } 72 | return nil, false 73 | } 74 | -------------------------------------------------------------------------------- /internal/web/cache/type.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/jsons" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // respCacheWriter 自定义的请求响应器 14 | type respCacheWriter struct { 15 | gin.ResponseWriter // gin 原始的响应器 16 | body *bytes.Buffer // gin 回写响应时, 同步缓存 17 | } 18 | 19 | func (rcw *respCacheWriter) Write(b []byte) (int, error) { 20 | rcw.body.Write(b) 21 | return rcw.ResponseWriter.Write(b) 22 | } 23 | 24 | // respCache 存放请求的响应信息 25 | type respCache struct { 26 | 27 | // code 响应码 28 | code int 29 | 30 | // body 响应体 31 | body []byte 32 | 33 | // cacheKey 缓存 key 34 | cacheKey string 35 | 36 | // expired 缓存过期时间戳 UnixMilli 37 | expired int64 38 | 39 | // header 响应头信息 40 | header respHeader 41 | 42 | // mu 读写互斥控制 43 | mu sync.RWMutex 44 | } 45 | 46 | // respHeader 记录特定请求的缓存参数 47 | type respHeader struct { 48 | expired string // 过期时间 49 | space string // 缓存空间名称 50 | spaceKey string // 缓存空间 key 51 | header http.Header // 原始请求的克隆请求头 52 | } 53 | 54 | // Code 响应码 55 | func (c *respCache) Code() int { 56 | c.mu.RLock() 57 | defer c.mu.RUnlock() 58 | return c.code 59 | } 60 | 61 | // Body 克隆一个响应体, 转换为缓冲区 62 | func (c *respCache) Body() *bytes.Buffer { 63 | c.mu.RLock() 64 | defer c.mu.RUnlock() 65 | return bytes.NewBuffer(c.BodyBytes()) 66 | } 67 | 68 | // BodyBytes 克隆一个响应体 69 | func (c *respCache) BodyBytes() []byte { 70 | c.mu.RLock() 71 | defer c.mu.RUnlock() 72 | return append([]byte(nil), c.body...) 73 | } 74 | 75 | // JsonBody 将响应体转化成 json 返回 76 | func (c *respCache) JsonBody() (*jsons.Item, error) { 77 | c.mu.RLock() 78 | defer c.mu.RUnlock() 79 | return jsons.New(string(c.body)) 80 | } 81 | 82 | // Header 获取响应头属性 83 | func (c *respCache) Header(key string) string { 84 | c.mu.RLock() 85 | defer c.mu.RUnlock() 86 | return c.header.header.Get(key) 87 | } 88 | 89 | // Headers 获取克隆响应头 90 | func (c *respCache) Headers() http.Header { 91 | c.mu.RLock() 92 | defer c.mu.RUnlock() 93 | return c.header.header.Clone() 94 | } 95 | 96 | // Space 获取缓存空间名称 97 | func (c *respCache) Space() string { 98 | c.mu.RLock() 99 | defer c.mu.RUnlock() 100 | return c.header.space 101 | } 102 | 103 | // SpaceKey 获取缓存空间 key 104 | func (c *respCache) SpaceKey() string { 105 | c.mu.RLock() 106 | defer c.mu.RUnlock() 107 | return c.header.spaceKey 108 | } 109 | 110 | // Update 更新缓存 111 | // 112 | // code 传递零值时, 会自动忽略更新 113 | // 114 | // body 传递 nil 时, 会自动忽略更新, 115 | // 传递空切片时, 会认为是一个空响应体进行更新 116 | // 117 | // header 传递 nil 时, 会自动忽略更新, 118 | // 不为 nil 时, 缓存的响应头会被清空, 并设置为新值 119 | func (c *respCache) Update(code int, body []byte, header http.Header) { 120 | if code == 0 && body == nil && header == nil { 121 | return 122 | } 123 | c.mu.Lock() 124 | defer c.mu.Unlock() 125 | 126 | if code != 0 { 127 | c.code = code 128 | } 129 | 130 | if body != nil { 131 | // 新建一个底层数组来存放响应体数据 132 | c.body = append(([]byte)(nil), body...) 133 | } 134 | 135 | if header != nil { 136 | c.header.header = header.Clone() 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /internal/web/handler.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "regexp" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/constant" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // MatchRouteKey 存储在 gin 上下文的路由匹配字段 13 | const MatchRouteKey = "matchRoute" 14 | 15 | // globalDftHandler 全局默认兜底的请求处理器 16 | func globalDftHandler(c *gin.Context) { 17 | if c.Request.Method == http.MethodHead { 18 | c.String(http.StatusOK, "") 19 | return 20 | } 21 | 22 | // 依次匹配路由规则, 找到其他的处理器 23 | for _, rule := range rules { 24 | reg := rule[0].(*regexp.Regexp) 25 | if reg.MatchString(c.Request.RequestURI) { 26 | c.Set(MatchRouteKey, reg.String()) 27 | c.Set(constant.RouteSubMatchGinKey, reg.FindStringSubmatch(c.Request.RequestURI)) 28 | rule[1].(gin.HandlerFunc)(c) 29 | return 30 | } 31 | } 32 | } 33 | 34 | // compileRules 编译路由的正则表达式 35 | func compileRules(rs [][2]any) [][2]any { 36 | newRs := make([][2]any, 0) 37 | for _, rule := range rs { 38 | reg, err := regexp.Compile(rule[0].(string)) 39 | if err != nil { 40 | log.Printf("路由正则编译失败, pattern: %v, error: %v", rule[0], err) 41 | continue 42 | } 43 | rule[0] = reg 44 | 45 | rawHandler, ok := rule[1].(func(*gin.Context)) 46 | if !ok { 47 | log.Printf("错误的请求处理器, pattern: %v", rule[0]) 48 | continue 49 | } 50 | var handler gin.HandlerFunc = rawHandler 51 | rule[1] = handler 52 | newRs = append(newRs, rule) 53 | } 54 | return newRs 55 | } 56 | -------------------------------------------------------------------------------- /internal/web/log.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/constant" 9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 10 | "github.com/AmbitiousJun/go-emby2alist/internal/util/https" 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | func CustomLogger(port string) gin.HandlerFunc { 15 | return func(c *gin.Context) { 16 | start := time.Now() 17 | 18 | // 处理请求 19 | c.Next() 20 | 21 | // 记录日志 22 | fmt.Printf("%s %s | %s | %s | %s | %s %s | %s %s\n", 23 | colors.ToYellow("[ge2a:"+constant.CurrentVersion+"]"), 24 | start.Format("2006-01-02 15:04:05"), 25 | colorStatusCode(c.Writer.Status()), 26 | time.Since(start), 27 | c.ClientIP(), 28 | colors.ToBlue(port), 29 | colors.ToBlue(c.GetString(MatchRouteKey)), 30 | colors.ToBlue(c.Request.Method), 31 | c.Request.RequestURI, 32 | ) 33 | } 34 | } 35 | 36 | // colorStatusCode 将响应码打上颜色标记 37 | func colorStatusCode(code int) string { 38 | str := strconv.Itoa(code) 39 | if https.IsSuccessCode(code) || https.IsRedirectCode(code) { 40 | return colors.ToGreen(str) 41 | } 42 | if https.IsErrorCode(code) { 43 | return colors.ToRed(str) 44 | } 45 | return colors.ToBlue(str) 46 | } 47 | -------------------------------------------------------------------------------- /internal/web/referer.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | const RefererControlHeaderKey = "Referrer-Policy" 8 | 9 | // referrerPolicySetter 设置代理的 Referrer 策略 10 | func referrerPolicySetter() gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | c.Header(RefererControlHeaderKey, "no-referrer") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/web/route.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/AmbitiousJun/go-emby2alist/internal/constant" 7 | "github.com/AmbitiousJun/go-emby2alist/internal/service/emby" 8 | "github.com/AmbitiousJun/go-emby2alist/internal/service/m3u8" 9 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | // rules 预定义路由拦截规则, 以及相应的处理器 15 | // 16 | // 每个规则为一个切片, 参数分别是: 正则表达式, 处理器 17 | var rules [][2]any 18 | 19 | func initRulePatterns() { 20 | log.Println(colors.ToBlue("正在初始化路由规则...")) 21 | rules = compileRules([][2]any{ 22 | // websocket 23 | {constant.Reg_Socket, emby.ProxySocket()}, 24 | 25 | // PlaybackInfo 接口 26 | {constant.Reg_PlaybackInfo, emby.TransferPlaybackInfo}, 27 | 28 | // 播放停止时, 辅助请求 Progress 记录进度 29 | {constant.Reg_PlayingStopped, emby.PlayingStoppedHelper}, 30 | // 拦截无效的进度报告 31 | {constant.Reg_PlayingProgress, emby.PlayingProgressHelper}, 32 | 33 | // Items 接口 34 | {constant.Reg_UserItems, emby.LoadCacheItems}, 35 | // 代理 Items 并添加转码版本信息 36 | {constant.Reg_UserEpisodeItems, emby.ProxyAddItemsPreviewInfo}, 37 | // 随机列表接口 38 | {constant.Reg_UserItemsRandomResort, emby.ResortRandomItems}, 39 | // 代理原始的随机列表接口, 去除 limit 限制, 并进行缓存 40 | {constant.Reg_UserItemsRandomWithLimit, emby.RandomItemsWithLimit}, 41 | 42 | // 重排序剧集 43 | {constant.Reg_ShowEpisodes, emby.ResortEpisodes}, 44 | 45 | // 字幕长时间缓存 46 | {constant.Reg_VideoSubtitles, emby.ProxySubtitles}, 47 | 48 | // 资源重定向到直链 49 | {constant.Reg_ResourceStream, emby.Redirect2AlistLink}, 50 | // master 重定向到本地 m3u8 代理 51 | {constant.Reg_ResourceMaster, emby.Redirect2Transcode}, 52 | // main 路由到直链接口 53 | {constant.Reg_ResourceMain, emby.Redirect2AlistLink}, 54 | // m3u8 转码播放列表 55 | {constant.Reg_ProxyPlaylist, m3u8.ProxyPlaylist}, 56 | // ts 重定向到直链 57 | {constant.Reg_ProxyTs, m3u8.ProxyTsLink}, 58 | // m3u8 字幕 59 | {constant.Reg_ProxySubtitle, m3u8.ProxySubtitle}, 60 | 61 | // 资源下载, 重定向到直链 62 | {constant.Reg_ItemDownload, emby.Redirect2AlistLink}, 63 | {constant.Reg_ItemSyncDownload, emby.HandleSyncDownload}, 64 | 65 | // 处理图片请求 66 | {constant.Reg_Images, emby.HandleImages}, 67 | 68 | // web cors 处理 69 | {constant.Reg_VideoModWebDefined, emby.ChangeBaseVideoModuleCorsDefined}, 70 | 71 | // 代理首页, 注入自定义脚本 72 | {constant.Reg_IndexHtml, emby.ProxyIndexHtml}, 73 | // 响应自定义脚本 74 | {constant.Route_CustomJs, emby.ProxyCustomJs}, 75 | // 响应自定义样式 76 | {constant.Route_CustomCss, emby.ProxyCustomCss}, 77 | 78 | // 其余资源走重定向回源 79 | {constant.Reg_All, emby.ProxyOrigin}, 80 | }) 81 | log.Println(colors.ToGreen("路由规则初始化完成")) 82 | } 83 | 84 | // initRoutes 初始化路由 85 | func initRoutes(r *gin.Engine) { 86 | r.Any("/*vars", globalDftHandler) 87 | } 88 | -------------------------------------------------------------------------------- /internal/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "crypto/tls" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 9 | "github.com/AmbitiousJun/go-emby2alist/internal/service/emby" 10 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 11 | "github.com/AmbitiousJun/go-emby2alist/internal/web/cache" 12 | "github.com/AmbitiousJun/go-emby2alist/internal/web/webport" 13 | 14 | "github.com/gin-gonic/gin" 15 | ) 16 | 17 | // Listen 监听指定端口 18 | func Listen() error { 19 | initRulePatterns() 20 | 21 | errChanHTTP, errChanHTTPS := make(chan error, 1), make(chan error, 1) 22 | if !config.C.Ssl.Enable { 23 | go listenHTTP(errChanHTTP) 24 | } else if config.C.Ssl.SinglePort { 25 | go listenHTTPS(errChanHTTPS) 26 | } else { 27 | go listenHTTP(errChanHTTP) 28 | go listenHTTPS(errChanHTTPS) 29 | } 30 | 31 | select { 32 | case err := <-errChanHTTP: 33 | log.Fatal("http 服务异常: ", err) 34 | case err := <-errChanHTTPS: 35 | log.Fatal("https 服务异常: ", err) 36 | } 37 | return nil 38 | } 39 | 40 | // initRouter 初始化路由引擎 41 | func initRouter(r *gin.Engine) { 42 | r.Use(referrerPolicySetter()) 43 | r.Use(emby.ApiKeyChecker()) 44 | r.Use(emby.DownloadStrategyChecker()) 45 | if config.C.Cache.Enable { 46 | r.Use(cache.CacheableRouteMarker()) 47 | r.Use(cache.RequestCacher()) 48 | } 49 | initRoutes(r) 50 | } 51 | 52 | // listenHTTP 在指定端口上监听 http 服务 53 | // 54 | // 出现错误时, 会写入 errChan 中 55 | func listenHTTP(errChan chan error) { 56 | r := gin.New() 57 | r.Use(gin.Recovery()) 58 | r.Use(CustomLogger(webport.HTTP)) 59 | r.Use(func(c *gin.Context) { 60 | c.Set(webport.GinKey, webport.HTTP) 61 | }) 62 | initRouter(r) 63 | log.Printf(colors.ToBlue("在端口【%s】上启动 HTTP 服务"), webport.HTTP) 64 | err := r.Run("0.0.0.0:" + webport.HTTP) 65 | errChan <- err 66 | close(errChan) 67 | } 68 | 69 | // listenHTTPS 在指定端口上监听 https 服务 70 | // 71 | // 出现错误时, 会写入 errChan 中 72 | func listenHTTPS(errChan chan error) { 73 | r := gin.New() 74 | r.Use(gin.Recovery()) 75 | r.Use(CustomLogger(webport.HTTPS)) 76 | r.Use(func(c *gin.Context) { 77 | c.Set(webport.GinKey, webport.HTTPS) 78 | }) 79 | initRouter(r) 80 | log.Printf(colors.ToBlue("在端口【%s】上启动 HTTPS 服务"), webport.HTTPS) 81 | ssl := config.C.Ssl 82 | 83 | srv := &http.Server{ 84 | Addr: "0.0.0.0:" + webport.HTTPS, 85 | Handler: r, 86 | } 87 | // 禁用 HTTP/2 88 | srv.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){} 89 | 90 | err := srv.ListenAndServeTLS(ssl.CrtPath(), ssl.KeyPath()) 91 | errChan <- err 92 | close(errChan) 93 | } 94 | -------------------------------------------------------------------------------- /internal/web/webport/webport.go: -------------------------------------------------------------------------------- 1 | package webport 2 | 3 | const ( 4 | HTTPS = "8094" 5 | HTTP = "8095" 6 | GinKey = "port" 7 | ) 8 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/AmbitiousJun/go-emby2alist/internal/config" 7 | "github.com/AmbitiousJun/go-emby2alist/internal/constant" 8 | "github.com/AmbitiousJun/go-emby2alist/internal/util/colors" 9 | "github.com/AmbitiousJun/go-emby2alist/internal/web" 10 | ) 11 | 12 | func main() { 13 | log.Println("正在加载配置...") 14 | if err := config.ReadFromFile("config.yml"); err != nil { 15 | log.Fatal(err) 16 | } 17 | 18 | printBanner() 19 | 20 | log.Println(colors.ToBlue("正在启动服务...")) 21 | if err := web.Listen(); err != nil { 22 | log.Fatal(colors.ToRed(err.Error())) 23 | } 24 | } 25 | 26 | func printBanner() { 27 | log.Printf(colors.ToYellow(` 28 | _ ____ _ _ _ 29 | __ _ ___ ___ _ __ ___ | |__ _ _|___ \ __ _| (_)___| |_ 30 | / _| |/ _ \ _____ / _ \ '_ | _ \| '_ \| | | | __) / _| | | / __| __| 31 | | (_| | (_) |_____| __/ | | | | | |_) | |_| |/ __/ (_| | | \__ \ |_ 32 | \__, |\___/ \___|_| |_| |_|_.__/ \__, |_____\__,_|_|_|___/\__| 33 | |___/ |___/ 34 | 35 | Repository: %s 36 | Version: %s 37 | `), constant.RepoAddr, constant.CurrentVersion) 38 | } 39 | -------------------------------------------------------------------------------- /ssl/README.md: -------------------------------------------------------------------------------- 1 | ## SSL 证书统一放置处 2 | 3 | 将 `.crt` 和 `.key` 文件放在这个目录下,再将他们的名称配置到 `config.yml` 中即可 --------------------------------------------------------------------------------