feat: Emby 远程挂载(联邦聚合)功能 beta

- 新增 emby_remote 提供方:通过 STRM 账号体系配置远程 Emby 地址/用户名/密码
- EmbyService 聚合远程媒体库 Views/Items/详情/最近添加/全局搜索,远程数据不落库
- 条目 ID 统一伪装为 embyremote~{account}~{id},请求按账号路由回远程
- 播放支持账号级 proxy_play 配置:默认客户端直连远程;开启后由 MMTL 反向代理流/字幕
- 图片/元数据全部实时透传远程,播放状态(已看/收藏)透传回远程 Emby
- cloud 包新增 emby Provider(Ping/List/Resolve),账号测试/目录浏览自动生效
- 前端 STRM 页面支持 Emby 远程挂载账号(地址+凭据+代理开关)
This commit is contained in:
truewhile
2026-09-01 10:44:01 +08:00
parent 89d7a6cbb2
commit 0eb3f104f4
21 changed files with 1748 additions and 15 deletions
+37 -5
View File
@@ -34,10 +34,19 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
// embySubtitleStreamHandler serves an external subtitle track advertised in a
// MediaSource's MediaStreams via its Emby index
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
// sideloaded subtitle file next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams.
// sideloaded subtitle track next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams. 远程 Emby
// 条目的字幕直接反向代理远程。
func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteSubtitle(c.Request.Context(), c.Writer, c.Request, accountID, remoteID, c.Param("index")); err != nil {
embyError(c, http.StatusNotFound, "subtitle not found")
return
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
@@ -213,12 +222,26 @@ func embyAppendAPIKey(raw, token string) string {
return u.String()
}
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口。
// 远程 Emby 条目(embyremote~ 前缀)走反向代理;本地条目直接代理到
// /api/stream/{id}(同一个 ServeFile)。
func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteVideoStream(c.Request.Context(), c.Writer, c.Request, accountID, remoteID); err != nil {
if errors.Is(err, service.ErrEmbyRemoteNotFound) {
c.Status(http.StatusNotFound)
return
}
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
item, err := svc.Emby.Item(c.Request.Context(), encodedID, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -296,6 +319,11 @@ func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container,
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 远程 Emby 条目不做本地转码(播放地址已由 PlaybackInfo 指向远程/代理直连)。
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
@@ -319,6 +347,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {