diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml new file mode 100644 index 0000000..d6dee8a --- /dev/null +++ b/.github/workflows/beta-build.yml @@ -0,0 +1,133 @@ +# Beta 分支自动构建流水线 +# +# 触发:push 到 beta 分支 / PR 到 beta / 手动触发。 +# 产出: +# 1. 前端 + 后端编译验证(go vet / go test / go build) +# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64) +# 3. ghcr.io/{owner}/mmtl:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64) +# +# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、 +# 不打 release tag,只构建带 -beta 标识的产物供测试。 + +name: Beta Build + +on: + push: + branches: [beta] + pull_request: + branches: [beta] + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + BETA_VERSION_PREFIX: beta + +jobs: + # ───────────────────────────────────────────────────────────────────────────── + # 1) 编译验证 + 多平台二进制产物 + # ───────────────────────────────────────────────────────────────────────────── + test-and-build: + name: Test & build artifacts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve beta version + id: version + run: | + BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") + SHA_SHORT=${GITHUB_SHA:0:7} + echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + + # The binary embeds the SPA (web/dist) via go:embed, so dist must exist + # before the Go toolchain touches the web package. + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + - name: Build SPA + working-directory: web + run: | + npm ci + npm run build + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build (host) + run: go build ./... + + # 多平台可执行文件(嵌入刚构建的 web/dist) + - name: Build linux/amd64 + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-amd64 ./cmd/server + - name: Build linux/arm64 + run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-arm64 ./cmd/server + - name: Build windows/amd64 + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-windows-amd64.exe ./cmd/server + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: mmtl-beta-binaries + path: dist/* + if-no-files-found: error + + # ───────────────────────────────────────────────────────────────────────────── + # 2) Beta Docker 镜像(ghcr.io/{owner}/mmtl:beta) + # ───────────────────────────────────────────────────────────────────────────── + docker-beta: + name: Build & push beta Docker image + needs: test-and-build + runs-on: ubuntu-latest + # PR 事件不推送镜像,仅 push beta / 手动触发时推送 + if: github.event_name != 'pull_request' + steps: + - uses: actions/checkout@v4 + + - name: Resolve beta version + id: version + run: | + BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") + SHA_SHORT=${GITHUB_SHA:0:7} + echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + sbom: false + tags: ghcr.io/${{ github.repository_owner }}/mmtl:beta + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.repository }} + build-args: | + VERSION=${{ steps.version.outputs.full_version }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/cmd/server/router.go b/cmd/server/router.go index cef84eb..a073a8b 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -173,6 +173,11 @@ func isFrontendLibraryRoute(path string) bool { if strings.Contains(id, "/") { return false } + // 远程 Emby 挂载库的伪装 ID(embyremote~account~remote)也是前端库路由, + // 需要交给 SPA 而非当作 Emby API 路径 404。 + if strings.HasPrefix(id, "embyremote~") { + return true + } if len(id) != 36 { return false } diff --git a/internal/handler/emby_mounts.go b/internal/handler/emby_mounts.go new file mode 100644 index 0000000..c1b6a85 --- /dev/null +++ b/internal/handler/emby_mounts.go @@ -0,0 +1,198 @@ +// Emby 挂载管理 HTTP 层:远程 Emby 服务器(账号)下的媒体库挂载 CRUD, +// 以及账号远程媒体库(View)列表预览。 +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MMTL/internal/model" + "github.com/ShukeBta/MMTL/internal/service" +) + +// embyMountView 挂载的对外 JSON(附带账号信息)。 +type embyMountView struct { + model.EmbyMount + AccountName string `json:"account_name"` +} + +// embyMountInput 创建挂载的请求体(单个或批量)。 +type embyMountInput struct { + AccountID string `json:"account_id" binding:"required"` + Views []embyViewInput `json:"views" binding:"required,min=1"` +} + +type embyViewInput struct { + RemoteViewID string `json:"remote_view_id" binding:"required"` + RemoteViewName string `json:"remote_view_name"` + CollectionType string `json:"collection_type"` + Name string `json:"name"` + ProxyPlay bool `json:"proxy_play"` +} + +func embyMountViews(mounts []model.EmbyMount, accounts map[string]string) []embyMountView { + out := make([]embyMountView, 0, len(mounts)) + for _, m := range mounts { + out = append(out, embyMountView{EmbyMount: m, AccountName: accounts[m.AccountID]}) + } + return out +} + +// embyAccountViewsHandler 列出账号上的远程媒体库(View),供挂载选择。 +func embyAccountViewsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id")) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"}) + return + } + views, err := svc.EmbyRemote.RemoteViews(c.Request.Context(), acct) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + type viewEntry struct { + RemoteViewID string `json:"remote_view_id"` + RemoteViewName string `json:"remote_view_name"` + CollectionType string `json:"collection_type"` + ChildCount int `json:"child_count"` + AlreadyMounted bool `json:"already_mounted"` + } + mounted := map[string]bool{} + if mounts, err := svc.EmbyRemote.ListMountsByAccount(c.Request.Context(), acct.ID); err == nil { + for _, m := range mounts { + mounted[m.RemoteViewID] = true + } + } + out := make([]viewEntry, 0, len(views)) + for _, v := range views { + viewID := service.RemoteItemIDString(v) + if strings.TrimSpace(viewID) == "" { + continue + } + out = append(out, viewEntry{ + RemoteViewID: viewID, + RemoteViewName: service.RemoteItemNameString(v), + CollectionType: service.RemoteItemCollectionType(v), + ChildCount: service.RemoteItemChildCount(v), + AlreadyMounted: mounted[viewID], + }) + } + c.JSON(http.StatusOK, out) + } +} + +// listEmbyMountsHandler 列出全部挂载。 +func listEmbyMountsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + mounts, err := svc.EmbyRemote.ListMounts(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + names := map[string]string{} + if accounts, err := svc.EmbyRemote.ListAccounts(c.Request.Context()); err == nil { + for _, a := range accounts { + names[a.ID] = a.Name + } + } + out := embyMountViews(mounts, names) + if out == nil { + out = []embyMountView{} + } + c.JSON(http.StatusOK, out) + } +} + +// createEmbyMountsHandler 批量创建挂载(同一账号下的多个远程媒体库)。 +func createEmbyMountsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req embyMountInput + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + mounts := make([]*model.EmbyMount, 0, len(req.Views)) + for _, v := range req.Views { + mounts = append(mounts, &model.EmbyMount{ + AccountID: req.AccountID, + RemoteViewID: v.RemoteViewID, + RemoteViewName: v.RemoteViewName, + CollectionType: v.CollectionType, + Name: v.Name, + ProxyPlay: v.ProxyPlay, + Enabled: true, + }) + } + if _, err := svc.EmbyRemote.CreateMounts(c.Request.Context(), mounts); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "created": len(mounts)}) + } +} + +// fullMountEmbyAccountHandler 全量挂载:把账号所有远程媒体库一次挂载进来。 +func fullMountEmbyAccountHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id")) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"}) + return + } + proxy := c.Query("proxy") == "1" || c.Query("proxy") == "true" + n, err := svc.EmbyRemote.FullMountAccount(c.Request.Context(), acct, proxy) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "created": n}) + } +} + +// updateEmbyMountHandler 更新挂载(显示名 / 代理开关 / 启用)。 +func updateEmbyMountHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req struct { + Name *string `json:"name"` + ProxyPlay *bool `json:"proxy_play"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + mount, err := svc.EmbyRemote.MountByID(c.Request.Context(), c.Param("id")) + if err != nil || mount == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "挂载不存在"}) + return + } + if req.Name != nil { + mount.Name = *req.Name + } + if req.ProxyPlay != nil { + mount.ProxyPlay = *req.ProxyPlay + } + if req.Enabled != nil { + mount.Enabled = *req.Enabled + } + if _, err := svc.EmbyRemote.UpdateMount(c.Request.Context(), mount.ID, mount); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, mount) + } +} + +// deleteEmbyMountHandler 删除挂载。 +func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if err := svc.EmbyRemote.DeleteMount(c.Request.Context(), c.Param("id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} \ No newline at end of file diff --git a/internal/handler/emby_playback.go b/internal/handler/emby_playback.go index 0d1e303..e720980 100644 --- a/internal/handler/emby_playback.go +++ b/internal/handler/emby_playback.go @@ -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 { diff --git a/internal/handler/media.go b/internal/handler/media.go index 12bdec7..9a46e51 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -25,9 +25,32 @@ type createLibraryReq struct { CreatePerSubfolder bool `json:"create_per_subfolder"` } +// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库 +// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。 +type webLibraryPayload struct { + model.Library + IsRemoteEmby bool `json:"is_remote_emby,omitempty"` + RemoteSource string `json:"remote_source,omitempty"` + Total int64 `json:"total,omitempty"` + Cards []service.SeriesCard `json:"cards,omitempty"` +} + +// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目, +// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。 +func remoteLibraryItemTypes(collectionType string) string { + switch collectionType { + case "movies": + return "Movie" + case "tvshows": + return "Series" + } + return "" +} + func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - libs, err := svc.Media.ListLibraries(c.Request.Context()) + ctx := c.Request.Context() + libs, err := svc.Media.ListLibraries(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -35,39 +58,82 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") if !includeHidden { - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) + libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs) visibility := mediaVisibilityForRequest(c, svc) filtered := libs[:0] for _, lib := range libs { - if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) { + if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) { filtered = append(filtered, lib) } } libs = filtered } withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true" + limit := 10 if withPreview { - limit, _ := strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10"))) + limit, _ = strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10"))) if limit <= 0 { limit = 10 } else if limit > 100 { limit = 100 } - previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), limit) + } + out := make([]webLibraryPayload, 0, len(libs)+8) + if withPreview { + previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, previews) - return + for _, p := range previews { + out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards}) + } + } else { + for _, l := range libs { + out = append(out, webLibraryPayload{Library: l}) + } } - c.JSON(http.StatusOK, libs) + // 远程 Emby 挂载库追加在本地库之后。 + if svc.EmbyRemote != nil { + if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil { + for _, v := range views { + wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} + if withPreview { + if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil { + tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}} + itemTypes := remoteLibraryItemTypes(v.CollectionType) + if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil { + wl.Total = total + } + if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil { + wl.Cards = cards + } + } + } + out = append(out, wl) + } + } + } + c.JSON(http.StatusOK, out) } } func getLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id")) + ctx := c.Request.Context() + id := c.Param("id") + // 远程 Emby 挂载库详情。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) + view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID) + if err != nil || view == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName}) + return + } + lib, err := svc.Repo.Library.FindByID(ctx, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -79,14 +145,14 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc { role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") if !includeHidden { - libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib}) - if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { + libs := service.FilterDisplayCloudLibraries(ctx, svc.Repo, []model.Library{*lib}) + if len(libs) == 0 || !service.LibraryVisibleForUser(ctx, svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - c.JSON(http.StatusOK, libs[0]) + c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]}) } else { - c.JSON(http.StatusOK, lib) + c.JSON(http.StatusOK, webLibraryPayload{Library: *lib}) } } } @@ -232,8 +298,37 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { func listMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + ctx := c.Request.Context() page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + // 远程 Emby 库:转发远程直属条目并映射为本地 Media 结构(分页由远程承接)。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + itemTypes := "" + if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil { + itemTypes = remoteLibraryItemTypes(view.CollectionType) + } + items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, mount, acct, remoteID, itemTypes, (page-1)*size, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if items == nil { + items = []model.Media{} + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "page_size": size, + }) + return + } groupVersions := c.DefaultQuery("group_versions", "1") != "0" if !groupVersions { items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc)) @@ -271,7 +366,33 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { func getMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + ctx := c.Request.Context() + id := c.Param("id") + // 远程 Emby 条目:拉远程详情并映射为本地 Media 结构。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if m == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if !mediaVisibleForRequest(c, svc, m) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, m) + return + } + m, err := svc.Media.GetMedia(ctx, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -365,7 +486,33 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc { func streamHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + ctx := c.Request.Context() + id := c.Param("id") + // 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if mount.ProxyPlay { + if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil { + if !c.Writer.Written() { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } + } + return + } + target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.Redirect(http.StatusFound, target) + return + } + m, err := svc.Media.GetMedia(ctx, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 0f5ce22..de2a76b 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -46,6 +46,14 @@ func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container) } func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { + // Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts) + admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc)) + admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc)) + admin.GET("/emby/mounts", listEmbyMountsHandler(svc)) + admin.POST("/emby/mounts", createEmbyMountsHandler(svc)) + admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc)) + admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc)) + admin.GET("/strm/accounts", listStrmAccountsHandler(svc)) admin.POST("/strm/accounts", createStrmAccountHandler(svc)) admin.PUT("/strm/accounts/:id", updateStrmAccountHandler(svc)) diff --git a/internal/handler/series.go b/internal/handler/series.go index 7b7ada2..f0e39b7 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -65,7 +65,49 @@ func listSeasonsHandler(svc *service.Container) gin.HandlerFunc { func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { libID := c.Param("id") - if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil { + ctx := c.Request.Context() + // 远程剧集库:远程 Series 映射为系列卡片。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, mount, acct, remoteID) + if err != nil { + writeInternalOrCanceled(c, err) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500")) + if page < 1 { + page = 1 + } + if size <= 0 || size > 1000 { + size = 500 + } + start := (page - 1) * size + if start > len(cards) { + start = len(cards) + } + end := start + size + if end > len(cards) { + end = len(cards) + } + pageItems := cards[start:end] + if pageItems == nil { + pageItems = []service.SeriesCard{} + } + c.JSON(http.StatusOK, gin.H{ + "items": pageItems, + "total": len(cards), + "page": page, + "page_size": size, + }) + return + } + if lib, err := svc.Repo.Library.FindByID(ctx, libID); err == nil && lib != nil { if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -114,7 +156,27 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "key is required"}) return } - if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil { + ctx := c.Request.Context() + // 远程系列 key(伪装系列 ID):转发远程该系列全部剧集。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(key) { + mountID, remoteSeriesID, _ := service.DecodeEmbyRemoteID(key) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID) + if err != nil { + writeInternalOrCanceled(c, err) + return + } + if items == nil { + items = []model.Media{} + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) + return + } + if lib, err := svc.Repo.Library.FindByID(ctx, libID); err == nil && lib != nil { if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -136,7 +198,27 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } - items, err := svc.Media.ListMediaEpisodes(c.Request.Context(), id, mediaVisibilityForRequest(c, svc)) + ctx := c.Request.Context() + // 远程条目:单集→同系列集列表;系列/季/文件夹→子集;电影→自身单条。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteID) + if err != nil { + writeInternalOrCanceled(c, err) + return + } + if items == nil { + items = []model.Media{} + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) + return + } + items, err := svc.Media.ListMediaEpisodes(ctx, id, mediaVisibilityForRequest(c, svc)) if err != nil { writeInternalOrCanceled(c, err) return diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 8f897fc..ead666f 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -28,17 +28,25 @@ type strmAccountView struct { model.StrmAccount HasCredential bool `json:"has_credential"` ProviderLabel string `json:"provider_label"` + // ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。 + ProxyPlay *bool `json:"proxy_play,omitempty"` } -func strmAccountViews(accounts []model.StrmAccount) []strmAccountView { +func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []strmAccountView { out := make([]strmAccountView, 0, len(accounts)) for i := range accounts { a := accounts[i] - out = append(out, strmAccountView{ + view := strmAccountView{ StrmAccount: a, HasCredential: service.HasStrmAccountCredential(&a), ProviderLabel: providerLabelOf(a.Provider), - }) + } + if a.Provider == model.StrmProviderEmbyRemote && svc != nil && svc.EmbyRemote != nil { + if proxyPlay, err := svc.EmbyRemote.ProxyPlayOf(&a); err == nil { + view.ProxyPlay = &proxyPlay + } + } + out = append(out, view) } return out } @@ -58,7 +66,7 @@ func listStrmAccountsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, strmAccountViews(accounts)) + c.JSON(http.StatusOK, strmAccountViews(svc, accounts)) } } @@ -74,7 +82,7 @@ func createStrmAccountHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - views := strmAccountViews([]model.StrmAccount{*acct}) + views := strmAccountViews(svc, []model.StrmAccount{*acct}) c.JSON(http.StatusOK, views[0]) } } @@ -92,7 +100,7 @@ func updateStrmAccountHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - views := strmAccountViews([]model.StrmAccount{*acct}) + views := strmAccountViews(svc, []model.StrmAccount{*acct}) c.JSON(http.StatusOK, views[0]) } } @@ -114,7 +122,7 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) return } - views := strmAccountViews([]model.StrmAccount{*acct}) + views := strmAccountViews(svc, []model.StrmAccount{*acct}) c.JSON(http.StatusOK, views[0]) } } diff --git a/internal/model/emby_mount.go b/internal/model/emby_mount.go new file mode 100644 index 0000000..5b81806 --- /dev/null +++ b/internal/model/emby_mount.go @@ -0,0 +1,19 @@ +// Emby 媒体库挂载模型。 +// +// 远程 Emby 账号(StrmAccount.Provider = emby_remote)只是一个服务器连接; +// 「挂载」才决定把该服务器的哪个媒体库(View)暴露到本项目的媒体库中。 +// 这样同一个 Emby 服务器可以按库选择挂载,且每个挂载独立控制是否由 MMTL +// 代理播放流量。 +package model + +// EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。 +type EmbyMount struct { + Base + AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote) + RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id + RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余) + CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ... + Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」) + ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理 + Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示 +} \ No newline at end of file diff --git a/internal/model/model.go b/internal/model/model.go index 6d7d355..36c02aa 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -58,5 +58,6 @@ func AllModels() []interface{} { &StrmUploadTask{}, &StrmDirCache{}, &ScrapeTask{}, + &EmbyMount{}, } } diff --git a/internal/model/strm.go b/internal/model/strm.go index e85a4c7..add4d67 100644 --- a/internal/model/strm.go +++ b/internal/model/strm.go @@ -13,6 +13,7 @@ const ( StrmProviderCloudDrive = "clouddrive2" // CloudDrive2(WebDAV 桥接) StrmProviderOpenList = "openlist" // OpenList / AList 兼容桥接 StrmProviderLocal = "local" // 本地目录(无账号) + StrmProviderEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关聚合挂载,不走 STRM 同步) ) // StrmAccount 是一个网盘账号(STRM 同步数据源凭据)。 diff --git a/internal/repository/emby_mount_repository.go b/internal/repository/emby_mount_repository.go new file mode 100644 index 0000000..9de20b0 --- /dev/null +++ b/internal/repository/emby_mount_repository.go @@ -0,0 +1,93 @@ +package repository + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + + "github.com/ShukeBta/MMTL/internal/model" +) + +// EmbyMountRepository 持久化远程 Emby 媒体库挂载。 +type EmbyMountRepository struct{ db *gorm.DB } + +func (r *EmbyMountRepository) Create(ctx context.Context, m *model.EmbyMount) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Create(m).Error + }) +} + +func (r *EmbyMountRepository) CreateInBatches(ctx context.Context, mounts []*model.EmbyMount, batchSize int) error { + if len(mounts) == 0 { + return nil + } + if batchSize <= 0 { + batchSize = 50 + } + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).CreateInBatches(mounts, batchSize).Error + }) +} + +func (r *EmbyMountRepository) FindByID(ctx context.Context, id string) (*model.EmbyMount, error) { + var m model.EmbyMount + err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (r *EmbyMountRepository) List(ctx context.Context) ([]model.EmbyMount, error) { + var rows []model.EmbyMount + err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error + return rows, err +} + +func (r *EmbyMountRepository) ListByAccountID(ctx context.Context, accountID string) ([]model.EmbyMount, error) { + var rows []model.EmbyMount + err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("created_at asc").Find(&rows).Error + return rows, err +} + +func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error + return count, err +} + +func (r *EmbyMountRepository) Update(ctx context.Context, m *model.EmbyMount) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("id = ?", m.ID).Updates(map[string]any{ + "name": m.Name, + "proxy_play": m.ProxyPlay, + "enabled": m.Enabled, + "remote_view_id": m.RemoteViewID, + "remote_view_name": m.RemoteViewName, + "collection_type": m.CollectionType, + "updated_at": time.Now(), + }).Error + }) +} + +func (r *EmbyMountRepository) Delete(ctx context.Context, id string) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.EmbyMount{}).Error + }) +} + +// DeleteByAccountID 删除账号下全部挂载(删除账号时级联清理)。 +func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID string) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Where("account_id = ?", accountID).Delete(&model.EmbyMount{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} \ No newline at end of file diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 2ebe721..cc8d738 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -33,6 +33,7 @@ type Container struct { StrmUpload *StrmUploadTaskRepository StrmDirCache *StrmDirCacheRepository ScrapeTask *ScrapeTaskRepository + EmbyMount *EmbyMountRepository } // New 将每个 repository 连接到单个 *gorm.DB。 @@ -62,5 +63,6 @@ func New(db *gorm.DB) *Container { StrmUpload: &StrmUploadTaskRepository{db: db}, StrmDirCache: &StrmDirCacheRepository{db: db}, ScrapeTask: &ScrapeTaskRepository{db: db}, + EmbyMount: &EmbyMountRepository{db: db}, } } diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go index d334f99..9c4cdd1 100644 --- a/internal/service/cloud/cloud.go +++ b/internal/service/cloud/cloud.go @@ -30,6 +30,7 @@ const ( Type115 = "cloud115" // 115 网盘 TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘 TypeOpenList = "openlist" // OpenList / AList-compatible bridge + TypeEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关挂载) ) // ErrUnsupported is returned for an unknown provider type. @@ -101,6 +102,8 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error) return newCloudDrive2(cfg, client), nil case TypeOpenList: return newOpenList(cfg, client), nil + case TypeEmbyRemote: + return newEmby(cfg, client), nil default: return nil, ErrUnsupported } @@ -108,7 +111,7 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error) // IsCloudType reports whether typ is a cloud-disk provider. func IsCloudType(typ string) bool { - return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList + return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList || typ == TypeEmbyRemote } // str coerces a config value to a trimmed string. diff --git a/internal/service/cloud/emby.go b/internal/service/cloud/emby.go new file mode 100644 index 0000000..b2677ee --- /dev/null +++ b/internal/service/cloud/emby.go @@ -0,0 +1,246 @@ +// Emby remote provider: exposes a remote Emby server through the same +// Provider interface used by cloud disks, so account CRUD / connectivity +// test / directory browser work unchanged. This is a thin adapter — the +// federated Emby API aggregation (Views / Items / PlaybackInfo / streaming +// proxy) lives in service.EmbyRemoteService and does not go through the +// cloud-disk sync machinery. +package cloud + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// Emby 远程挂载类型(service 层聚合走 EmbyRemoteService,不走 STRM 同步)。 + +// embyProvider implements Provider against a remote Emby server using an +// api_key (token) for authentication. DirectLink.Resolve returns the remote +// stream URL; whether MMTL reverse-proxies the bytes is decided by the +// emby.proxy_play account config (defaults to off). +type embyProvider struct { + base string // e.g. http://host:8096(自动补 /emby 前缀) + username string + password string + token string // api_key + userID string // 远程用户 Id + proxyPlay bool + client *http.Client +} + +type embyUserPayload struct { + Id string `json:"Id"` +} + +type embyLoginResponse struct { + AccessToken string `json:"AccessToken"` + User embyUserPayload `json:"User"` +} + +type embyPingResponse struct { + ServerName string `json:"ServerName"` +} + +// newEmby builds the provider from the account config map. +func newEmby(cfg map[string]any, client *http.Client) Provider { + p := &embyProvider{ + base: strings.TrimRight(str(cfg["url"]), "/"), + username: str(cfg["username"]), + password: str(cfg["password"]), + token: firstNonEmpty(str(cfg["api_key"]), str(cfg["token"])), + userID: str(cfg["remote_user_id"]), + proxyPlay: boolish(cfg["proxy_play"]), + client: client, + } + if p.client == nil { + p.client = &http.Client{Transport: &embyUATransport{base: http.DefaultTransport}} + } + return p +} + +// embyUATransport 给远程 Emby 请求注入浏览器 UA(防 Cloudflare 风控拦截)。 +type embyUATransport struct { + base http.RoundTripper +} + +func (t *embyUATransport) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.TrimSpace(req.Header.Get("User-Agent")) == "" { + req.Header.Set("User-Agent", defaultUA) + } + return t.base.RoundTrip(req) +} + +// embyBase normalizes the address so requests go to /emby/... endpoints. +func (p *embyProvider) embyBase() string { + base := strings.TrimRight(p.base, "/") + if !strings.Contains(base, "/emby") { + base += "/emby" + } + return base +} + +// externalBase 不追加 /emby(内嵌媒体资源 URL 使用 /emby 会更贴近习惯,此处 +// 与 embyBase 保持一致:所有端点统一以 /emby 开头)。 +func (p *embyProvider) apiBase() string { return p.embyBase() } + +func (p *embyProvider) Type() string { return TypeEmbyRemote } + +// Ping 验证地址连通性与凭据(/System/Info)。 +func (p *embyProvider) Ping(ctx context.Context) error { + if p.base == "" { + return errors.New("缺少 Emby 地址") + } + token, err := p.ensureToken(ctx) + if err != nil { + return err + } + return p.doJSON(ctx, http.MethodGet, "/System/Info", nil, token, &embyPingResponse{}) +} + +// doJSON 向远程 Emby 发起带 api_key 的请求并解析 JSON 响应。 +func (p *embyProvider) doJSON(ctx context.Context, method, path string, body io.Reader, token string, out any) error { + endpoint := p.apiBase() + path + if token != "" { + sep := "?" + if strings.Contains(endpoint, "?") { + sep = "&" + } + endpoint += sep + "api_key=" + url.QueryEscape(token) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return err + } + req.Header.Set("X-Emby-Token", token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := p.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + if resp.StatusCode == http.StatusUnauthorized { + return ErrEmbyUnauthorized + } + data, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// ErrEmbyUnauthorized 表示远程凭据失效(触发重新认证/打回测试)。 +var ErrEmbyUnauthorized = errors.New("emby 认证失败或凭据已失效") + +// ensureToken 返回可用 api_key:已有则直接用,否则尝试账号密码认证。 +func (p *embyProvider) ensureToken(ctx context.Context) (string, error) { + if strings.TrimSpace(p.token) != "" { + return p.token, nil + } + if strings.TrimSpace(p.username) == "" { + return "", errors.New("缺少 Emby 凭据(token 或 用户名/密码)") + } + payload := map[string]string{"Username": p.username, "Pw": p.password} + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase()+"/Users/AuthenticateByName", strings.NewReader(string(data))) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MMTL", Device="MMTL-Federated", DeviceId="mmtl-federated", Version="1.0"`) + resp, err := p.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return "", fmt.Errorf("emby 登录失败(%d)", resp.StatusCode) + } + var login embyLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&login); err != nil { + return "", err + } + if strings.TrimSpace(login.AccessToken) == "" { + return "", errors.New("emby 登录成功但未返回 AccessToken") + } + p.token = login.AccessToken + if login.User.Id != "" { + p.userID = login.User.Id + } + return p.token, nil +} + +// embyItemSummary 目录浏览所需的最小 Emby 条目字段。 +type embyItemSummary struct { + Id string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + ChildCount int `json:"ChildCount"` + RunTimeTicks int64 `json:"RunTimeTicks"` +} + +type embyItemListResponse struct { + Items []embyItemSummary `json:"Items"` +} + +// List 把远程媒体库(View)展开为目录树:dirID 为空=媒体库列表;否则返回该 +// 目录(Movie/Series/Season/Folder)下的条目。用于账号「浏览目录」调试入口。 +func (p *embyProvider) List(ctx context.Context, dirID string) ([]FileEntry, error) { + token, err := p.ensureToken(ctx) + if err != nil { + return nil, err + } + userID := p.userID + if userID == "" { + userID = "0" // 某些 Emby 允许用 0 代表管理员 + } + path := "/Users/" + url.PathEscape(userID) + "/Items" + if dirID != "" { + path += "?ParentId=" + url.QueryEscape(dirID) + } else { + path += "?IncludeItemTypes=CollectionFolder" + } + var out embyItemListResponse + if err := p.doJSON(ctx, http.MethodGet, path, nil, token, &out); err != nil { + return nil, err + } + entries := make([]FileEntry, 0, len(out.Items)) + for _, it := range out.Items { + size := int64(0) + if it.RunTimeTicks > 0 { + size = it.RunTimeTicks / 10_000_000 // 秒 + } + entries = append(entries, FileEntry{ + ID: it.Id, + Name: it.Name, + IsDir: it.IsFolder || it.Type != "Movie", + Size: size, + }) + } + return entries, nil +} + +// Resolve 返回远程 Emby 直链。Proxy=true 时由调用方(StrmService.ProxyDirect) +// 反向代理流量;false 时 302 到直链。默认不代理(播放字节不经过 MMTL)。 +func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) { + token, err := p.ensureToken(ctx) + if err != nil { + return nil, err + } + u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream" + u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef) + return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil +} \ No newline at end of file diff --git a/internal/service/cloud/emby_test.go b/internal/service/cloud/emby_test.go new file mode 100644 index 0000000..f17e693 --- /dev/null +++ b/internal/service/cloud/emby_test.go @@ -0,0 +1,136 @@ +package cloud + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fakeEmbyServer 记录请求,按路径返回远程 Emby 风格响应。 +func fakeEmbyServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/emby/Users/AuthenticateByName": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"AccessToken":"remote-token","User":{"Id":"user-9"}}`)) + case r.URL.Path == "/emby/System/Info": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ServerName":"RemoteEmby"}`)) + case r.URL.Path == "/emby/Users/user-9/Items" && r.URL.Query().Get("ParentId") == "": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Items":[{"Id":"view-1","Name":"Movies","Type":"CollectionFolder","IsFolder":true}]}`)) + case r.URL.Path == "/emby/Users/user-9/Items": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Items":[{"Id":"movie-1","Name":"Avatar","Type":"Movie","IsFolder":false}]}`)) + case strings.Contains(r.URL.Path, "/emby/Videos/movie-1/stream"): + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("fake-video-bytes")) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) +} + +func TestEmbyProviderPingAuthenticatesAndGetsToken(t *testing.T) { + srv := fakeEmbyServer(t) + defer srv.Close() + + p, err := New(TypeEmbyRemote, map[string]any{ + "url": srv.URL, + "username": "alice", + "password": "secret", + }, srv.Client()) + if err != nil { + t.Fatal(err) + } + if err := p.Ping(context.Background()); err != nil { + t.Fatalf("ping: %v", err) + } + // 认证成功后 token 被记住,第二次 Ping 不应再走登录。 + if err := p.Ping(context.Background()); err != nil { + t.Fatalf("ping 2: %v", err) + } +} + +func TestEmbyProviderListViewsAndChildren(t *testing.T) { + srv := fakeEmbyServer(t) + defer srv.Close() + + p, err := New(TypeEmbyRemote, map[string]any{ + "url": srv.URL, + "api_key": "fixed-token", + "remote_user_id": "user-9", + }, srv.Client()) + if err != nil { + t.Fatal(err) + } + root, err := p.List(context.Background(), "") + if err != nil { + t.Fatalf("list root: %v", err) + } + if len(root) != 1 || root[0].Name != "Movies" || !root[0].IsDir { + t.Fatalf("root listing = %+v", root) + } + children, err := p.List(context.Background(), "view-1") + if err != nil { + t.Fatalf("list children: %v", err) + } + if len(children) != 1 || children[0].Name != "Avatar" || children[0].ID != "movie-1" { + t.Fatalf("children = %+v", children) + } +} + +func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) { + srv := fakeEmbyServer(t) + defer srv.Close() + + p, err := New(TypeEmbyRemote, map[string]any{ + "url": srv.URL, + "api_key": "fixed-token", + "remote_user_id": "user-9", + }, srv.Client()) + if err != nil { + t.Fatal(err) + } + link, err := p.Resolve(context.Background(), "movie-1") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !strings.Contains(link.URL, "/emby/Videos/movie-1/stream") { + t.Fatalf("url = %q", link.URL) + } + if !strings.Contains(link.URL, "api_key=fixed-token") { + t.Fatalf("url missing api_key: %q", link.URL) + } + // 默认不代理播放流量。 + if link.Proxy { + t.Fatal("emby remote must not proxy by default") + } +} + +func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) { + srv := fakeEmbyServer(t) + defer srv.Close() + + p, err := New(TypeEmbyRemote, map[string]any{ + "url": srv.URL, + "api_key": "fixed-token", + "remote_user_id": "user-9", + "proxy_play": "true", + }, srv.Client()) + if err != nil { + t.Fatal(err) + } + link, err := p.Resolve(context.Background(), "movie-1") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !link.Proxy { + t.Fatal("proxy_play=true must mark link as proxied") + } + if link.URL == "" { + t.Fatal("proxy link must still carry the remote URL") + } +} \ No newline at end of file diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index b079d7f..57e1318 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -11,6 +11,15 @@ import ( // ImageURL returns artwork for a media/series/season item id. func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (string, error) { + // 远程 Emby 条目:直接返回远程图片绝对地址,由 ImageProxy 拉取透传。 + if e.remote != nil && IsEmbyRemoteID(id) { + mountID, remoteID, _ := DecodeEmbyRemoteID(id) + mount, acct, _ := e.remote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + return "", nil + } + return e.remote.RemoteImageURL(ctx, acct, remoteID, imageType) + } pick := func(primary, backdrop string) string { switch strings.ToLower(imageType) { case "backdrop", "art": diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index b46652f..a775e12 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -48,6 +48,7 @@ type EmbyService struct { repo *repository.Container cache *RuntimeCacheService subtitle *SubtitleService + remote *EmbyRemoteService // 远程 Emby 联邦聚合(可为 nil:未启用) virtualMu sync.RWMutex virtualSeries map[string]embySeriesCacheEntry @@ -66,6 +67,14 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai return &EmbyService{cfg: cfg, log: log, repo: repo} } +// SetEmbyRemote 注入远程 Emby 联邦聚合服务(nil 表示未启用)。 +func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService { + if e != nil { + e.remote = remote + } + return e +} + func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService { if e != nil { e.cache = cache @@ -123,7 +132,8 @@ type embyVisibilityCacheEntry struct { // Items paginates media in Emby's hierarchy. Episodic libraries are exposed as // Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every -// episode as a separate movie card. +// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动 +// 路由到远程 Emby(联邦聚合,远程数据不落库)。 func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) { if p.Limit <= 0 || p.Limit > 500 { p.Limit = 50 @@ -135,6 +145,22 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, return emptyItemsEnvelope(p.StartIndex), nil } + if e.remote != nil { + // 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。 + if IsEmbyRemoteID(p.ParentID) { + mountID, _, _ := DecodeEmbyRemoteID(p.ParentID) + mount, acct, _ := e.remote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + return emptyItemsEnvelope(p.StartIndex), nil + } + return e.remote.RemoteItems(ctx, mount, acct, p) + } + // 全局搜索:无 ParentId 且带搜索词 → 聚合本地 + 全部远程。 + if p.ParentID == "" && p.SearchTerm != "" { + return e.aggregatedSearch(ctx, p) + } + } + if len(p.IDs) > 0 { items := make([]map[string]any, 0, len(p.IDs)) for _, id := range p.IDs { @@ -212,3 +238,85 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, } return e.mediaItems(ctx, p) } + +// aggregatedSearch 把本地媒体库与全部启用的远程 Emby 的搜索结果合并为一个 +// 分页载荷。本地结果保持原有分页语义,远程各自取一页(Limit 同款)后按 +// SortBy 做稳定排序切片。 +func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[string]any, error) { + local, err := e.mediaItems(ctx, p) + if err != nil { + return nil, err + } + type remoteResult struct { + items []any + } + mounts, aerr := e.remote.ListMounts(ctx) + results := make([]remoteResult, 0, len(mounts)) + if aerr == nil { + for i := range mounts { + m := mounts[i] + if !m.Enabled { + continue + } + acct := e.remote.AccountByID(ctx, m.AccountID) + if acct == nil { + continue + } + // 按挂载逐个搜索:搜索结果归属明确(伪装 ID 正确),也天然只搜已 + // 挂载的媒体库。 + searchParams := p + searchParams.ParentID = "" // RemoteSearchMount 内部设 ParentId + remote, rerr := e.remote.RemoteSearchMount(ctx, &m, acct, p) + if rerr != nil { + if e.log != nil { + e.log.Warn("remote emby search failed", + zap.String("account", acct.Name), zap.Error(rerr)) + } + continue + } + if raw, ok := remote["Items"].([]any); ok { + results = append(results, remoteResult{items: raw}) + } else if rawMap, ok := remote["Items"].([]map[string]any); ok { + converted := make([]any, 0, len(rawMap)) + for _, m := range rawMap { + converted = append(converted, any(m)) + } + results = append(results, remoteResult{items: converted}) + } + } + } + items := make([]any, 0, len(localItemsAsAny(local))+len(results)*p.Limit) + items = append(items, localItemsAsAny(local)...) + for _, res := range results { + items = append(items, res.items...) + } + return sliceSearchItems(items, p), nil +} + +func localItemsAsAny(envelope map[string]any) []any { + if envelope == nil { + return nil + } + if raw, ok := envelope["Items"].([]any); ok { + return raw + } + if raw, ok := envelope["Items"].([]map[string]any); ok { + converted := make([]any, 0, len(raw)) + for _, m := range raw { + converted = append(converted, any(m)) + } + return converted + } + return nil +} + +// sliceSearchItems 对合并结果按请求排序做简单归类后分页。远程返回已按远程 +// 排序规则排好,这里保持稳定顺序,只做首/尾切片,避免过度重排造成分页跳动。 +func sliceSearchItems(items []any, p ItemsParams) map[string]any { + total := len(items) + if p.StartIndex >= total { + return map[string]any{"Items": []any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex} + } + end := minInt(p.StartIndex+p.Limit, total) + return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex} +} diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 8510b3a..8182b03 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -11,6 +11,15 @@ import ( // Item 单条目详情。 func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) { + // 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。 + if e.remote != nil && IsEmbyRemoteID(mediaID) { + mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID) + mount, acct, _ := e.remote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + return nil, nil + } + return e.remote.RemoteItem(ctx, mount, acct, remoteID) + } if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil { return nil, err } else if lib != nil { @@ -71,11 +80,19 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str return e.itemPayload(ctx, m, fav, pos), nil } -// LatestItems 最近添加,全库或指定库。 +// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。 func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) { if limit <= 0 || limit > 100 { limit = 20 } + if e.remote != nil && IsEmbyRemoteID(parentID) { + mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID) + mount, acct, _ := e.remote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + return nil, nil + } + return e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit) + } cacheKey := e.embyLatestCacheKey(userID, parentID, limit) var cached embyLatestCacheValue if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) { diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index 6999b41..b97d927 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "net/url" "path/filepath" "strconv" @@ -14,7 +15,25 @@ import ( ) // PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients. +// 远程 Emby 条目直接转发远程 PlaybackInfo,并按账号 proxy_play 配置决定 +// 播放地址指向远程(直连)还是 MMTL 本地代理端点。 func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) { + if e.remote != nil && IsEmbyRemoteID(mediaID) { + mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID) + mount, acct, err := e.remote.ResolveMount(ctx, mountID) + if err != nil { + return nil, ErrEmbyRemoteNotFound + } + out, err := e.remote.RemotePlaybackInfo(ctx, mount, acct, remoteID, userID) + if err != nil { + return nil, err + } + if out == nil { + return nil, ErrEmbyRemoteNotFound + } + out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix()) + return out, nil + } m, err := e.playableMedia(ctx, mediaID, userID) if err != nil || m == nil { return nil, err @@ -25,6 +44,65 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) }, nil } +// ErrEmbyRemoteNotFound 表示伪装 ID 对应的远程挂载账号不存在/已禁用。 +var ErrEmbyRemoteNotFound = fmt.Errorf("remote emby account not found") + +// RemoteAccountByID 供 handler 层解码伪装 ID 后获取远程账号。 +func (e *EmbyService) RemoteAccountByID(ctx context.Context, accountID string) *model.StrmAccount { + if e == nil || e.remote == nil { + return nil + } + return e.remote.AccountByID(ctx, accountID) +} + +// ProxyRemoteVideoStream 反向代理远程 Emby 视频流(保留 Range)。 +func (e *EmbyService) ProxyRemoteVideoStream(ctx context.Context, w http.ResponseWriter, r *http.Request, mountID, remoteID string) error { + if e == nil || e.remote == nil { + return ErrEmbyRemoteNotFound + } + _, acct, err := e.remote.ResolveMount(ctx, mountID) + if err != nil { + return ErrEmbyRemoteNotFound + } + return e.remote.ProxyVideoStream(ctx, w, r, acct, remoteID) +} + +// ProxyRemoteSubtitle 反向代理远程 Emby 字幕流。 +func (e *EmbyService) ProxyRemoteSubtitle(ctx context.Context, w http.ResponseWriter, r *http.Request, mountID, remoteID, index string) error { + if e == nil || e.remote == nil { + return ErrSubtitleNotFound + } + _, acct, err := e.remote.ResolveMount(ctx, mountID) + if err != nil { + return ErrSubtitleNotFound + } + return e.remote.ProxySubtitle(ctx, w, r, acct, remoteID, index) +} + +// ProxyRemoteSetPlayed 把已看/未看状态透传到远程 Emby。 +func (e *EmbyService) ProxyRemoteSetPlayed(ctx context.Context, mountID, remoteID string, played bool) error { + if e == nil || e.remote == nil { + return ErrEmbyRemoteNotFound + } + _, acct, err := e.remote.ResolveMount(ctx, mountID) + if err != nil { + return ErrEmbyRemoteNotFound + } + return e.remote.ProxySetPlayed(ctx, acct, remoteID, played) +} + +// ProxyRemoteSetFavorite 把收藏/取消收藏状态透传到远程 Emby。 +func (e *EmbyService) ProxyRemoteSetFavorite(ctx context.Context, mountID, remoteID string, favorite bool) error { + if e == nil || e.remote == nil { + return ErrEmbyRemoteNotFound + } + _, acct, err := e.remote.ResolveMount(ctx, mountID) + if err != nil { + return ErrEmbyRemoteNotFound + } + return e.remote.ProxySetFavorite(ctx, acct, remoteID, favorite) +} + // ServeSubtitleStream resolves the Emby /Videos/:id/Subtitles/:index/Stream // request to one of the media's sideloaded external subtitle tracks and writes // the original (unconverted) subtitle bytes to w — matching the source Codec @@ -39,6 +117,11 @@ func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, medi if e == nil || e.subtitle == nil { return ErrSubtitleUnavailable } + if e.remote != nil && IsEmbyRemoteID(mediaID) { + // 远程字幕由反向代理透传(需要 http.ResponseWriter 能力),handler 层 + // 已对远程 ID 走 ProxyRemoteSubtitle,这里不重复处理。 + return ErrSubtitleNotFound + } m, err := e.playableMedia(ctx, mediaID, userID) if err != nil || m == nil { return ErrSubtitleNotFound diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go new file mode 100644 index 0000000..31424fa --- /dev/null +++ b/internal/service/emby_remote.go @@ -0,0 +1,862 @@ +// EmbyRemoteService 是「远程 Emby 联邦聚合」核心:把挂载的远程 Emby 服务器 +// 作为外部媒体源,通过 MMTL 的 Emby 兼容 API 透出。 +// +// 设计要点: +// - 远程媒体的元数据完全不落库:每次请求实时向远程 Emby 拉取; +// - 条目 ID 用 embyremote~{accountID}~{remoteID} 伪装(见 emby_remote_ids.go), +// 客户端拿伪装 ID 回来时按账号路由回远程; +// - 播放分流由账号级 proxy_play 配置决定: +// 不代理(默认)= MediaSource 下发热门远程绝对 URL,播放字节完全不经过 MMTL; +// 代理 = 下发 MMTL 本地 /Videos/{encoded} 端点,由 ProxyVideoStream 反向拉流。 +// +// 配置复用 STRM 账号体系(StrmAccount.Provider = emby_remote),CRUD/加密/连通 +// 测试全部走既有 /admin/strm/accounts 接口,不需要新增数据表。 +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MMTL/internal/config" + "github.com/ShukeBta/MMTL/internal/model" + "github.com/ShukeBta/MMTL/internal/repository" +) + +// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。 +const embyRemoteHTTPTimeout = 15 * time.Second + +// embyRemoteUA 桌面浏览器 UA:远程 Emby 前方若有 Cloudflare/WAF 会拦截 +// Go-http-client 等非浏览器 UA(403 error code: 1010),必须伪装浏览器。 +const embyRemoteUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" + +// embyRemoteTransport 统一给远程请求注入浏览器 UA。 +type embyRemoteTransport struct { + base http.RoundTripper +} + +func (t *embyRemoteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.TrimSpace(req.Header.Get("User-Agent")) == "" { + req.Header.Set("User-Agent", embyRemoteUA) + } + return t.base.RoundTrip(req) +} + +// EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。 +type EmbyRemoteConfig struct { + BaseURL string // http://host:8096(无需 /emby 后缀) + Username string + Password string + Token string // api_key(手动填写或自动认证获得) + RemoteUserID string // 远程用户 Id(自动认证后回填) + ProxyPlay bool // true=播放流量经 MMTL 反向代理;false=客户端直连远程 +} + +// EmbyRemoteService 提供对远程 Emby 服务器的读写封装。 +type EmbyRemoteService struct { + cfg *config.Config + log *zap.Logger + repo *repository.Container + crypto *CryptoService + http *http.Client +} + +// NewEmbyRemoteService 构造远程 Emby 聚合服务。 +func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.Container, crypto *CryptoService) *EmbyRemoteService { + return &EmbyRemoteService{ + cfg: cfg, + log: log, + repo: repo, + crypto: crypto, + http: &http.Client{ + Timeout: embyRemoteHTTPTimeout, + Transport: &embyRemoteTransport{base: http.DefaultTransport}, + }, + } +} + +// ListAccounts 返回全部启用的远程 Emby 挂载账号。 +func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccount, error) { + accounts, err := r.repo.StrmAccount.List(ctx) + if err != nil { + return nil, err + } + out := make([]model.StrmAccount, 0, len(accounts)) + for i := range accounts { + if accounts[i].Provider == model.StrmProviderEmbyRemote { + out = append(out, accounts[i]) + } + } + return out, nil +} + +// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。 +func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount { + if strings.TrimSpace(id) == "" { + return nil + } + acct, err := r.repo.StrmAccount.FindByID(ctx, id) + if err != nil || acct == nil { + return nil + } + if acct.Provider != model.StrmProviderEmbyRemote || !acct.Enabled { + return nil + } + return acct +} + +// ─── 媒体库挂载管理 ───────────────────────────────────────────────────────────── + +// ListMounts 返回全部挂载。 +func (r *EmbyRemoteService) ListMounts(ctx context.Context) ([]model.EmbyMount, error) { + return r.repo.EmbyMount.List(ctx) +} + +// ListMountsByAccount 返回指定账号的挂载。 +func (r *EmbyRemoteService) ListMountsByAccount(ctx context.Context, accountID string) ([]model.EmbyMount, error) { + return r.repo.EmbyMount.ListByAccountID(ctx, accountID) +} + +// MountByID 按 ID 查挂载。 +func (r *EmbyRemoteService) MountByID(ctx context.Context, id string) (*model.EmbyMount, error) { + return r.repo.EmbyMount.FindByID(ctx, id) +} + +// CreateMount 创建一个挂载(校验账号类型与远程 View 编号)。 +func (r *EmbyRemoteService) CreateMount(ctx context.Context, m *model.EmbyMount) (*model.EmbyMount, error) { + if strings.TrimSpace(m.AccountID) == "" || strings.TrimSpace(m.RemoteViewID) == "" { + return nil, errors.New("缺少账号或远程媒体库") + } + if r.AccountByID(ctx, m.AccountID) == nil { + return nil, errors.New("远程 Emby 账号不存在或已禁用") + } + if err := r.repo.EmbyMount.Create(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +// CreateMounts 批量创建挂载(幂等:已存在的远程库自动跳过)。 +func (r *EmbyRemoteService) CreateMounts(ctx context.Context, mounts []*model.EmbyMount) (int, error) { + if len(mounts) == 0 { + return 0, nil + } + existing, err := r.repo.EmbyMount.ListByAccountID(ctx, mounts[0].AccountID) + if err != nil { + return 0, err + } + have := make(map[string]bool, len(existing)) + for _, e := range existing { + have[e.RemoteViewID] = true + } + fresh := make([]*model.EmbyMount, 0, len(mounts)) + for _, m := range mounts { + if m == nil || have[m.RemoteViewID] { + continue + } + fresh = append(fresh, m) + } + if len(fresh) == 0 { + return 0, nil + } + if err := r.repo.EmbyMount.CreateInBatches(ctx, fresh, 50); err != nil { + return 0, err + } + return len(fresh), nil +} + +// UpdateMount 更新挂载(名称 / 代理 / 启用)。 +func (r *EmbyRemoteService) UpdateMount(ctx context.Context, id string, m *model.EmbyMount) (*model.EmbyMount, error) { + existing, err := r.repo.EmbyMount.FindByID(ctx, id) + if err != nil || existing == nil { + return nil, errNotFoundOr(err, "挂载不存在") + } + existing.Name = strings.TrimSpace(m.Name) + existing.ProxyPlay = m.ProxyPlay + existing.Enabled = m.Enabled + if err := r.repo.EmbyMount.Update(ctx, existing); err != nil { + return nil, err + } + return existing, nil +} + +// DeleteMount 删除挂载。 +func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error { + return r.repo.EmbyMount.Delete(ctx, id) +} + +// FullMountAccount 把账号的全部远程媒体库(View)挂载进来(幂等,已存在跳过)。 +func (r *EmbyRemoteService) FullMountAccount(ctx context.Context, acct *model.StrmAccount, proxyPlayDefault bool) (int, error) { + views, err := r.RemoteViews(ctx, acct) + if err != nil { + return 0, err + } + mounts := make([]*model.EmbyMount, 0, len(views)) + for _, v := range views { + viewID := remoteItemString(v, "Id") + if viewID == "" { + continue + } + mounts = append(mounts, &model.EmbyMount{ + AccountID: acct.ID, + RemoteViewID: viewID, + RemoteViewName: remoteItemString(v, "Name"), + CollectionType: remoteItemString(v, "CollectionType"), + ProxyPlay: proxyPlayDefault, + Enabled: true, + }) + } + return r.CreateMounts(ctx, mounts) +} + +// ResolveMount 按伪装 ID 的第一段(挂载 ID)解析挂载与其所属账号。 +// 远程条目/媒体库的伪装 ID 格式:embyremote~{mountID}~{remoteID}。 +func (r *EmbyRemoteService) ResolveMount(ctx context.Context, mountID string) (*model.EmbyMount, *model.StrmAccount, error) { + mount, err := r.repo.EmbyMount.FindByID(ctx, mountID) + if err != nil || mount == nil || !mount.Enabled { + return nil, nil, errors.New("挂载不存在或已禁用") + } + acct := r.AccountByID(ctx, mount.AccountID) + if acct == nil { + return nil, nil, errors.New("远程 Emby 账号不存在或已禁用") + } + return mount, acct, nil +} + +// AutoSeedMounts 兼容迁移:已有 emby_remote 账号但没有任何挂载时,自动把 +// 其全部媒体库挂载进来(代理沿用账号旧配置),保证旧部署升级后媒体库不消失。 +// 幂等:每个账号只在挂载数为 0 时执行一次。 +func (r *EmbyRemoteService) AutoSeedMounts(ctx context.Context) { + accounts, err := r.ListAccounts(ctx) + if err != nil || len(accounts) == 0 { + return + } + for i := range accounts { + acct := &accounts[i] + count, err := r.repo.EmbyMount.CountByAccountID(ctx, acct.ID) + if err != nil || count > 0 { + continue + } + cfg, cfgErr := r.configOf(acct) + if cfgErr != nil { + continue + } + n, seedErr := r.FullMountAccount(ctx, acct, cfg.ProxyPlay) + if seedErr != nil { + if r.log != nil { + r.log.Warn("auto-seed emby mounts failed", + zap.String("account", acct.Name), zap.Error(seedErr)) + } + } else if n > 0 { + if r.log != nil { + r.log.Info("auto-seeded emby mounts", + zap.String("account", acct.Name), zap.Int("mounts", n)) + } + } + } +} + +// configOf 解密账号配置。 +func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) { + raw := map[string]string{} + if acct != nil && strings.TrimSpace(acct.Config) != "" { + if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil { + return nil, fmt.Errorf("decode emby account config: %w", err) + } + } + cfg := &EmbyRemoteConfig{ + BaseURL: strings.TrimRight(strings.TrimSpace(raw["url"]), "/"), + Username: strings.TrimSpace(raw["username"]), + Password: r.crypto.Decrypt(raw["password"]), + Token: firstNonEmptyStr(r.crypto.Decrypt(raw["api_key"]), r.crypto.Decrypt(raw["token"])), + RemoteUserID: strings.TrimSpace(raw["remote_user_id"]), + ProxyPlay: parseBoolSetting(raw["proxy_play"], false), + } + if cfg.BaseURL == "" { + return nil, errors.New("缺少 Emby 地址") + } + if !strings.HasPrefix(cfg.BaseURL, "http://") && !strings.HasPrefix(cfg.BaseURL, "https://") { + return nil, errors.New("Emby 地址必须以 http:// 或 https:// 开头") + } + return cfg, nil +} + +func firstNonEmptyStr(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// embyBase 把地址规范为不带尾部斜杠的 /emby 根。 +func (r *EmbyRemoteService) embyBase(cfg *EmbyRemoteConfig) string { + base := strings.TrimRight(cfg.BaseURL, "/") + if !strings.HasSuffix(base, "/emby") { + base += "/emby" + } + return base +} + +// ensureToken 返回可用的 api_key:已有则直接用;否则用用户名/密码认证并回写 +// 数据库(自动获得的 token 与 remote_user_id 会加密保存在账号配置里)。 +func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error { + if strings.TrimSpace(cfg.Token) != "" { + return nil + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return errors.New("缺少 Emby 凭据:请填写 api_key 或 用户名/密码") + } + body, _ := json.Marshal(map[string]string{"Username": cfg.Username, "Pw": cfg.Password}) + endpoint := r.embyBase(cfg) + "/Users/AuthenticateByName" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MMTL", Device="MMTL-Federated", DeviceId="mmtl-federated", Version="1.0"`) + resp, err := r.http.Do(req) + if err != nil { + return fmt.Errorf("连接远程 Emby 失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + var login struct { + AccessToken string `json:"AccessToken"` + User struct { + Id string `json:"Id"` + } `json:"User"` + } + if err := json.NewDecoder(resp.Body).Decode(&login); err != nil { + return err + } + if strings.TrimSpace(login.AccessToken) == "" { + return errors.New("远程 Emby 未返回 AccessToken") + } + cfg.Token = login.AccessToken + if login.User.Id != "" { + cfg.RemoteUserID = login.User.Id + } + return r.persistToken(ctx, acct, cfg) +} + +// persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。 +func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error { + if acct == nil { + return nil + } + raw := map[string]string{} + if strings.TrimSpace(acct.Config) != "" { + _ = json.Unmarshal([]byte(acct.Config), &raw) + } + raw["api_key"] = r.crypto.Encrypt(cfg.Token) + raw["remote_user_id"] = cfg.RemoteUserID + if strings.TrimSpace(raw["username"]) == "" { + raw["username"] = cfg.Username + } + data, err := json.Marshal(raw) + if err != nil { + return err + } + acct.Config = string(data) + return r.repo.StrmAccount.Update(ctx, acct) +} + +// doGet 向远程 Emby 发起带 api_key 的 GET,把响应 JSON 解码到 out。 +// 401 时自动重认证一次再重试(凭据过期场景)。 +func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error { + for attempt := 0; attempt < 2; attempt++ { + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return err + } + endpoint := r.embyBase(cfg) + path + if q != nil { + endpoint += "?" + q.Encode() + } else { + endpoint += "?api_key=" + url.QueryEscape(cfg.Token) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("X-Emby-Token", cfg.Token) + resp, err := r.http.Do(req) + if err != nil { + return fmt.Errorf("请求远程 Emby 失败: %w", err) + } + data, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + resp.Body.Close() + if readErr != nil { + return readErr + } + if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { + // token 失效:清空后重认证重试一次。 + cfg.Token = "" + if acct != nil { +raw := map[string]string{} + _ = json.Unmarshal([]byte(acct.Config), &raw) + delete(raw, "api_key") + enc, _ := json.Marshal(raw) + acct.Config = string(enc) + _ = r.repo.StrmAccount.Update(ctx, acct) + } + continue + } + if resp.StatusCode >= 300 { + return fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + if out == nil { + return nil + } + return json.Unmarshal(data, out) + } + return errors.New("远程 Emby 认证重试失败") +} + +// TestConnection 连通性测试:确保地址可达且凭据有效;成功时回写自动认证信息。 +func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.StrmAccount) error { + cfg, err := r.configOf(acct) + if err != nil { + return err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return err + } + var out json.RawMessage + return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out) +} + +// ProxyPlayOf 返回账号是否配置了播放代理(供账号列表/编辑回显)。 +func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) { + cfg, err := r.configOf(acct) + if err != nil { + return false, err + } + return cfg.ProxyPlay, nil +} + +// ─── 元数据 / 目录聚合 ───────────────────────────────────────────────────────── + +// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。 +func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + q := url.Values{"api_key": {cfg.Token}} + var body struct { + Items []map[string]any `json:"Items"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Views", q, &body); err != nil { + return nil, err + } + return body.Items, nil +} + +func (r *EmbyRemoteService) remoteUserID(cfg *EmbyRemoteConfig) string { + if strings.TrimSpace(cfg.RemoteUserID) != "" { + return cfg.RemoteUserID + } + return "0" // 未认证出的兜底:部分 Emby 接受 0 代表管理员 +} + +// RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。 +// p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。 +func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + _, remoteParent, _ := DecodeEmbyRemoteID(p.ParentID) + q := url.Values{} + if remoteParent != "" { + q.Set("ParentId", remoteParent) + } + q.Set("UserId", r.remoteUserID(cfg)) + q.Set("Limit", strconv.Itoa(p.Limit)) + q.Set("StartIndex", strconv.Itoa(p.StartIndex)) + if p.SearchTerm != "" { + q.Set("SearchTerm", p.SearchTerm) + } + if p.Recursive { + q.Set("Recursive", "true") + } + if p.SortBy != "" { + q.Set("SortBy", p.SortBy) + } + if p.SortOrder != "" { + q.Set("SortOrder", p.SortOrder) + } + if len(p.IncludeItemTypes) > 0 { + q.Set("IncludeItemTypes", strings.Join(p.IncludeItemTypes, ",")) + } + if len(p.Filters) > 0 { + q.Set("Filters", strings.Join(p.Filters, ",")) + } + path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items" + var out map[string]any + if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil { + return nil, err + } + if out == nil { + out = map[string]any{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex} + } + RewriteEmbyRemoteIDs(out, mount.ID) + return out, nil +} + +// RemoteSearchMount 对单个挂载的媒体库执行全局搜索(ParentId=挂载的远程库, +// Recursive 返回库内全部命中),结果归属明确可直接伪装。 +func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("ParentId", mount.RemoteViewID) + q.Set("Recursive", "true") + q.Set("SearchTerm", p.SearchTerm) + q.Set("UserId", r.remoteUserID(cfg)) + q.Set("Limit", strconv.Itoa(p.Limit)) + q.Set("StartIndex", strconv.Itoa(p.StartIndex)) + if p.SortBy != "" { + q.Set("SortBy", p.SortBy) + } + if p.SortOrder != "" { + q.Set("SortOrder", p.SortOrder) + } + if len(p.IncludeItemTypes) > 0 { + q.Set("IncludeItemTypes", strings.Join(p.IncludeItemTypes, ",")) + } + var out map[string]any + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &out); err != nil { + return nil, err + } + if out == nil { + out = map[string]any{"Items": []any{}, "TotalRecordCount": 0} + } + RewriteEmbyRemoteIDs(out, mount.ID) + return out, nil +} + +// RemoteItem 拉取远程单条目详情(含响应的重写)。 +func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID) + var out map[string]any + if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil { + return nil, err + } + RewriteEmbyRemoteIDs(out, mount.ID) + return out, nil +} + +// RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。 +func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + q := url.Values{"Limit": {strconv.Itoa(limit)}} + if parentID != "" { + q.Set("ParentId", parentID) + } + path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/Latest" + var out []map[string]any + if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil { + return nil, err + } + RewriteEmbyRemoteIDs(out, mount.ID) + return out, nil +} + +// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写 +// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MMTL);代理=指向 MMTL +// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。 +func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + q := url.Values{"UserId": {r.remoteUserID(cfg)}} + path := "/Items/" + url.PathEscape(remoteID) + "/PlaybackInfo" + var out map[string]any + if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil { + return nil, err + } + RewriteEmbyRemoteIDs(out, mount.ID) + r.rewritePlayURLs(out, mount, cfg, remoteID) + return out, nil +} + +// rewritePlayURLs 按挂载代理模式重写载荷内 MediaSources 的播放地址。 +// 远程 Emby 的 PlaybackInfo 通常不返回 DirectStreamUrl(客户端靠它拼 +// /Videos/{Id}/stream),因此这里总是强制构造播放地址,完全由 MMTL 掌控 +// 直连(远程绝对 URL)或代理(本地 /Videos/{encoded})的最终去向。 +func (r *EmbyRemoteService) rewritePlayURLs(value any, mount *model.EmbyMount, cfg *EmbyRemoteConfig, remoteID string) { + encoded := EncodeEmbyRemoteID(mount.ID, remoteID) + sources := collectMediaSources(value) + if sources == nil { + return + } + for _, src := range sources { + mediaSourceID, _ := src["Id"].(string) + var streamPath, subtitlePlayURL string + if mount.ProxyPlay { + streamPath = "/Videos/" + url.PathEscape(encoded) + "/stream" + subtitlePlayURL = "/Videos/" + url.PathEscape(encoded) + } else { + base := r.embyBase(cfg) + streamPath = base + "/Videos/" + url.PathEscape(remoteID) + "/stream?api_key=" + url.QueryEscape(cfg.Token) + "&Static=true" + if mediaSourceID != "" { + streamPath += "&MediaSourceId=" + url.QueryEscape(mediaSourceID) + } + subtitlePlayURL = base + "/Videos/" + url.PathEscape(remoteID) + } + // 直连/代理地址总是下发(PlaybackInfo 语义:客户端直接请求该 URL)。 + src["DirectStreamUrl"] = streamPath + if _, exists := src["TranscodingUrl"]; exists { + src["TranscodingUrl"] = streamPath + } + rewriteSubtitleDeliveryURLs(src, subtitlePlayURL, cfg) + } +} + +// collectMediaSources 从载荷中取出所有 MediaSources(顶层或嵌套 Items 内)。 +func collectMediaSources(value any) []map[string]any { + var out []map[string]any + switch typed := value.(type) { + case map[string]any: + if sources, ok := typed["MediaSources"].([]map[string]any); ok { + out = append(out, sources...) + } else if sources, ok := typed["MediaSources"].([]any); ok { + for _, s := range sources { + if m, isMap := s.(map[string]any); isMap { + out = append(out, m) + } + } + } + if items, ok := typed["Items"]; ok { + out = append(out, collectMediaSources(items)...) + } + case []any: + for _, item := range typed { + out = append(out, collectMediaSources(item)...) + } + case []map[string]any: + for _, item := range typed { + out = append(out, collectMediaSources(item)...) + } + } + return out +} + +var embySubtitleDeliveryRE = regexp.MustCompile(`/Subtitles/(\d+)/Stream(\.[A-Za-z0-9]+)?`) + +// rewriteSubtitleDeliveryURLs 把 MediaSource 内字幕轨道的 DeliveryUrl 改写到 +// subtitlePlayURL 前缀(客户端请求本地代理端点 / 远程绝对地址)。 +func rewriteSubtitleDeliveryURLs(src map[string]any, playURL string, cfg *EmbyRemoteConfig) { + if src == nil { + return + } + streams, ok := src["MediaStreams"].([]any) + if !ok { + return + } + for _, s := range streams { + stream, isMap := s.(map[string]any) + if !isMap || stream["Type"] != "Subtitle" { + continue + } + raw, _ := stream["DeliveryUrl"].(string) + if raw == "" { + continue + } + idx := "1" + if m := embySubtitleDeliveryRE.FindStringSubmatch(raw); len(m) >= 2 { + idx = m[1] + } + ext := "" + if m := embySubtitleDeliveryRE.FindStringSubmatch(raw); len(m) >= 3 { + ext = m[2] + } + base := strings.TrimRight(playURL, "/") + delivery := base + "/Subtitles/" + idx + "/Stream" + ext + if !cfg.ProxyPlay && strings.TrimSpace(cfg.Token) != "" { + delivery += "?api_key=" + url.QueryEscape(cfg.Token) + } + stream["DeliveryUrl"] = delivery + } +} + +// RemoteImageURL 构造远程图片绝对地址(由既有 ImageProxy 拉取透传)。 +func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.StrmAccount, remoteID, imageType string) (string, error) { + cfg, err := r.configOf(acct) + if err != nil { + return "", err + } + return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(strings.ToLower(imageType)) + + "?api_key=" + url.QueryEscape(cfg.Token), nil +} + +// ─── 播放代理 ───────────────────────────────────────────────────────────────── + +// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。 +func (r *EmbyRemoteService) ProxyVideoStream(ctx context.Context, w http.ResponseWriter, req *http.Request, acct *model.StrmAccount, remoteID string) error { + cfg, err := r.configOf(acct) + if err != nil { + return err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return err + } + endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/stream" + q := url.Values{} + if mediaSourceID := strings.TrimSpace(req.URL.Query().Get("MediaSourceId")); mediaSourceID != "" { + q.Set("MediaSourceId", mediaSourceID) + } + // 代理是纯 byte 中继:始终要求远程原文件直连(Static=true 阻止远程触发 + // ffmpeg 转码调度——远程转码可能未配置/故障,导致整个代理 500)。 + q.Set("Static", "true") + q.Set("api_key", cfg.Token) + if encoded := q.Encode(); encoded != "" { + endpoint += "?" + encoded + } + upstream, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + upstream.Header.Set("X-Emby-Token", cfg.Token) + if rangeHeader := req.Header.Get("Range"); rangeHeader != "" { + upstream.Header.Set("Range", rangeHeader) + } + resp, err := r.http.Do(upstream) + if err != nil { + return fmt.Errorf("连接远程 Emby 视频流失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} { + if value := resp.Header.Get(header); value != "" { + w.Header().Set(header, value) + } + } + if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK { + w.WriteHeader(resp.StatusCode) + } else { + w.WriteHeader(resp.StatusCode) + } + if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK { + _, _ = io.Copy(w, resp.Body) + } + return nil +} + +// ProxySubtitle 反向代理远程 Emby 字幕流。 +func (r *EmbyRemoteService) ProxySubtitle(ctx context.Context, w http.ResponseWriter, req *http.Request, acct *model.StrmAccount, remoteID, index string) error { + cfg, err := r.configOf(acct) + if err != nil { + return err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return err + } + endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/Subtitles/" + url.PathEscape(index) + "/Stream" + endpoint += "?api_key=" + url.QueryEscape(cfg.Token) + upstream, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + upstream.Header.Set("X-Emby-Token", cfg.Token) + resp, err := r.http.Do(upstream) + if err != nil { + return fmt.Errorf("连接远程 Emby 字幕流失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("远程 Emby 字幕流失败(%d)", resp.StatusCode) + } + if value := resp.Header.Get("Content-Type"); value != "" { + w.Header().Set("Content-Type", value) + } + w.WriteHeader(resp.StatusCode) + if resp.StatusCode == http.StatusOK { + _, _ = io.Copy(w, resp.Body) + } + return nil +} + +// ─── 播放状态透传 ────────────────────────────────────────────────────────────── + +// ProxySetPlayed 把「已看/未看」状态透传到远程 Emby(MMTL 本地不落库)。 +func (r *EmbyRemoteService) ProxySetPlayed(ctx context.Context, acct *model.StrmAccount, remoteID string, played bool) error { + cfg, err := r.configOf(acct) + if err != nil { + return err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return err + } + method := http.MethodPost + path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/PlayedItems/" + url.PathEscape(remoteID) + if !played { + method = http.MethodDelete + } + return r.doMutate(ctx, acct, cfg, method, path) +} + +// ProxySetFavorite 把「收藏/取消收藏」状态透传到远程 Emby。 +func (r *EmbyRemoteService) ProxySetFavorite(ctx context.Context, acct *model.StrmAccount, remoteID string, favorite bool) error { + cfg, err := r.configOf(acct) + if err != nil { + return err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return err + } + method := http.MethodPost + path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/FavoriteItems/" + url.PathEscape(remoteID) + if !favorite { + method = http.MethodDelete + } + return r.doMutate(ctx, acct, cfg, method, path) +} + +func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, method, path string) error { + endpoint := r.embyBase(cfg) + path + "?api_key=" + url.QueryEscape(cfg.Token) + req, err := http.NewRequestWithContext(ctx, method, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("X-Emby-Token", cfg.Token) + resp, err := r.http.Do(req) + if err != nil { + return fmt.Errorf("请求远程 Emby 失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + return nil +} \ No newline at end of file diff --git a/internal/service/emby_remote_ids.go b/internal/service/emby_remote_ids.go new file mode 100644 index 0000000..342d39c --- /dev/null +++ b/internal/service/emby_remote_ids.go @@ -0,0 +1,109 @@ +// 远程 Emby 聚合的 ID 伪装。 +// +// MMTL 作为 Emby 联邦网关把多个远程 Emby 服务器的媒体库透明聚合到自身的 +// Emby API 之下,远程条目完全不落库。为了把本地 ID 与多个远程服务器的 ID +// 隔离开,远程条目在返回给客户端之前统一被改写为: +// +// embyremote~{accountID}~{remoteID} +// +// 客户端后续对图片 / 详情 / 播放 / 播放状态 的请求都会携带这个伪装 ID, +// 服务端据此解码出对应账号与原始 ID,直接向远程 Emby 转发。 +package service + +import ( + "strings" +) + +// EmbyRemoteIDPrefix 远程条目伪装 ID 的前缀(本地 UUID 与 Emby ID 不会出现 "~")。 +const EmbyRemoteIDPrefix = "embyremote~" + +// IsEmbyRemoteID 报告 id 是否是伪装过的远程 Emby 条目 ID。 +func IsEmbyRemoteID(id string) bool { + return strings.HasPrefix(id, EmbyRemoteIDPrefix) +} + +// EncodeEmbyRemoteID 把 (账号 ID, 远程条目 ID) 伪装为对外暴露的 ID。 +func EncodeEmbyRemoteID(accountID, remoteID string) string { + return EmbyRemoteIDPrefix + accountID + "~" + remoteID +} + +// DecodeEmbyRemoteID 拆分伪装 ID 为 (账号 ID, 远程原始 ID)。不是伪装 ID 时返回 +// ok=false。远程 ID 本身允许包含 "~"(使用 SplitN 只切第一刀)。 +func DecodeEmbyRemoteID(id string) (accountID, remoteID string, ok bool) { + if !IsEmbyRemoteID(id) { + return "", "", false + } + rest := strings.TrimPrefix(id, EmbyRemoteIDPrefix) + parts := strings.SplitN(rest, "~", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// embyRemoteStringIDs 是条目 JSON 中需要伪装(编码)成远程 ID 的字符串字段。 +// 图片 / 详情 / 播放请求都会以这些字段的值作为 ID 回指 MMTL。 +var embyRemoteStringIDs = []string{ + "Id", + "ParentId", + "SeriesId", + "SeasonId", + "PrimaryImageItemId", + "DisplayPreferencesId", +} + +// RewriteEmbyRemoteIDs 在内存中把远程 Emby 返回的载荷里的所有条目 ID 替换为 +// 伪装 ID(防止与本地、多远程冲突),嵌套 Items / Map 数组递归处理。 +// +// MediaSources 里的 Id / MediaSourceId 保持不变:客户端只把它们作为查询 +// 参数原样带回,转发时直接送回远程即可。播放 URL 的重写由服务层 +// (rewriteEmbyRemotePlayURLs)按直连/代理模式处理。 +func RewriteEmbyRemoteIDs(value any, accountID string) { + switch typed := value.(type) { + case map[string]any: + rewriteEmbyRemoteIDsMap(typed, accountID) + case []any: + for _, item := range typed { + RewriteEmbyRemoteIDs(item, accountID) + } + case []map[string]any: + for _, item := range typed { + rewriteEmbyRemoteIDsMap(item, accountID) + } + } +} + +func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) { + if m == nil { + return + } + for _, key := range embyRemoteStringIDs { + if raw, ok := m[key].(string); ok && raw != "" { + m[key] = EncodeEmbyRemoteID(accountID, raw) + } + } + if tags, ok := m["ImageTags"].(map[string]any); ok { + for k, v := range tags { + if s, isStr := v.(string); isStr && s != "" { + tags[k] = EncodeEmbyRemoteID(accountID, s) + } + } + } + if tags, ok := m["ImageTags"].(map[string]string); ok { + for k, v := range tags { + if v != "" { + tags[k] = EncodeEmbyRemoteID(accountID, v) + } + } + } + if tags, ok := m["BackdropImageTags"].([]any); ok { + for i := range tags { + if s, isStr := tags[i].(string); isStr && s != "" { + tags[i] = EncodeEmbyRemoteID(accountID, s) + } + } + } + if items, ok := m["Items"]; ok { + RewriteEmbyRemoteIDs(items, accountID) + } +} \ No newline at end of file diff --git a/internal/service/emby_remote_ids_test.go b/internal/service/emby_remote_ids_test.go new file mode 100644 index 0000000..473f315 --- /dev/null +++ b/internal/service/emby_remote_ids_test.go @@ -0,0 +1,102 @@ +package service + +import ( + "testing" +) + +func TestEmbyRemoteIDEncodeDecode(t *testing.T) { + encoded := EncodeEmbyRemoteID("acct-1", "item-123") + want := "embyremote~acct-1~item-123" + if encoded != want { + t.Fatalf("encoded = %q, want %q", encoded, want) + } + if !IsEmbyRemoteID(encoded) { + t.Fatalf("IsEmbyRemoteID(%q) = false", encoded) + } + acctID, remoteID, ok := DecodeEmbyRemoteID(encoded) + if !ok || acctID != "acct-1" || remoteID != "item-123" { + t.Fatalf("decode = (%q, %q, %v)", acctID, remoteID, ok) + } +} + +func TestDecodeEmbyRemoteIDAllowsTildeInRemoteID(t *testing.T) { + // 远程 ID 本身允许包含 "~":只切第一刀。 + encoded := EncodeEmbyRemoteID("acct-1", "a~b~c") + acctID, remoteID, ok := DecodeEmbyRemoteID(encoded) + if !ok || acctID != "acct-1" || remoteID != "a~b~c" { + t.Fatalf("decode = (%q, %q, %v)", acctID, remoteID, ok) + } +} + +func TestDecodeEmbyRemoteIDRejectsLocalUUIDs(t *testing.T) { + if _, _, ok := DecodeEmbyRemoteID("550e8400-e29b-41d4-a716-446655440000"); ok { + t.Fatal("local UUID must not decode as remote id") + } + if _, _, ok := DecodeEmbyRemoteID("embyremote~only-acct"); ok { + t.Fatal("malformed remote id must not decode") + } + if _, _, ok := DecodeEmbyRemoteID("embyremote~~"); ok { + t.Fatal("empty parts must not decode") + } +} + +func TestRewriteEmbyRemoteIDs(t *testing.T) { + payload := map[string]any{ + "Id": "item-1", + "ParentId": "folder-1", + "SeriesId": "series-1", + "SeasonId": "season-1", + "PrimaryImageItemId": "item-1", + "DisplayPreferencesId": "folder-1", + "ImageTags": map[string]any{ + "Primary": "item-1", + }, + "BackdropImageTags": []any{"item-1-bd"}, + "Items": []any{ + map[string]any{"Id": "item-2", "ParentId": "folder-2"}, + }, + // MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。 + "MediaSources": []any{ + map[string]any{ + "Id": "ms-9", + "DirectStreamUrl": "/Videos/item-1/stream", + "MediaStreams": []any{ + map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"}, + }, + }, + }, + } + RewriteEmbyRemoteIDs(payload, "acct-1") + + if got := payload["Id"]; got != "embyremote~acct-1~item-1" { + t.Fatalf("Id = %v", got) + } + if got := payload["ParentId"]; got != "embyremote~acct-1~folder-1" { + t.Fatalf("ParentId = %v", got) + } + if got := payload["SeriesId"]; got != "embyremote~acct-1~series-1" { + t.Fatalf("SeriesId = %v", got) + } + if got := payload["SeasonId"]; got != "embyremote~acct-1~season-1" { + t.Fatalf("SeasonId = %v", got) + } + if got := payload["ImageTags"].(map[string]any)["Primary"]; got != "embyremote~acct-1~item-1" { + t.Fatalf("ImageTags.Primary = %v", got) + } + if got := payload["BackdropImageTags"].([]any)[0]; got != "embyremote~acct-1~item-1-bd" { + t.Fatalf("BackdropImageTags[0] = %v", got) + } + nested := payload["Items"].([]any)[0].(map[string]any) + if nested["Id"] != "embyremote~acct-1~item-2" { + t.Fatalf("nested Id = %v", nested["Id"]) + } + + // MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。 + ms := payload["MediaSources"].([]any)[0].(map[string]any) + if ms["Id"] != "ms-9" { + t.Fatalf("MediaSource.Id must stay raw, got %v", ms["Id"]) + } + if ms["DirectStreamUrl"] != "/Videos/item-1/stream" { + t.Fatalf("DirectStreamUrl must stay raw, got %v", ms["DirectStreamUrl"]) + } +} \ No newline at end of file diff --git a/internal/service/emby_remote_playurl_test.go b/internal/service/emby_remote_playurl_test.go new file mode 100644 index 0000000..5b377e1 --- /dev/null +++ b/internal/service/emby_remote_playurl_test.go @@ -0,0 +1,54 @@ +package service + +import ( + "testing" +) + +// rewriteSubtitleDeliveryURLs 只应改动字幕轨道的 DeliveryUrl,其余媒体流不动。 +func TestRewriteSubtitleDeliveryURLsProxyMode(t *testing.T) { + src := map[string]any{ + "MediaStreams": []any{ + map[string]any{"Type": "Video", "DeliveryUrl": "/Videos/x/stream"}, + map[string]any{"Type": "Audio", "DeliveryUrl": "/Videos/x/stream"}, + map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/ms-9/Subtitles/2/Stream.srt"}, + }, + } + rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{}) + streams := src["MediaStreams"].([]any) + if got := streams[0].(map[string]any)["DeliveryUrl"]; got != "/Videos/x/stream" { + t.Fatalf("video DeliveryUrl must stay, got %v", got) + } + want := "/Videos/embyremote~acct-1~item-1/Subtitles/2/Stream.srt" + if got := streams[2].(map[string]any)["DeliveryUrl"]; got != want { + t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) + } +} + +func TestRewriteSubtitleDeliveryURLsDirectMode(t *testing.T) { + src := map[string]any{ + "MediaStreams": []any{ + map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/ms-9/Subtitles/1/Stream.ass"}, + }, + } + cfg := &EmbyRemoteConfig{Token: "tok123"} + rewriteSubtitleDeliveryURLs(src, "http://remote:8096/emby/Videos/item-1", cfg) + streams := src["MediaStreams"].([]any) + want := "http://remote:8096/emby/Videos/item-1/Subtitles/1/Stream.ass?api_key=tok123" + if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { + t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) + } +} + +func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) { + src := map[string]any{ + "MediaStreams": []any{ + map[string]any{"Type": "Subtitle", "DeliveryUrl": "custom/url"}, + }, + } + rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{}) + streams := src["MediaStreams"].([]any) + want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream" + if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { + t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) + } +} \ No newline at end of file diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go new file mode 100644 index 0000000..301f96d --- /dev/null +++ b/internal/service/emby_remote_web.go @@ -0,0 +1,587 @@ +// 网页端远程 Emby 库映射。 +// +// 网页端(React UI)的媒体库/媒体浏览走项目自有 REST API(/api/libraries、 +// /api/libraries/:id/media、/api/media/:id 等),数据结构为 model.Library / +// model.Media / SeriesCard。远程 Emby 挂载的数据不落库,因此这里把远程 +// Emby 的 JSON item 映射为与本地完全一致的结构,让网页端无感知地浏览 +// 远程库;播放统一走 /api/stream/{伪装ID}(302 到远程 Emby 原地址)。 +package service + +import ( + "context" + "net/url" + "sort" + "strconv" + "strings" + + "go.uber.org/zap" + + "github.com/ShukeBta/MMTL/internal/model" +) + +// RemoteLibraryView 是一个网页端可见的远程媒体库(对应一个挂载的远程 View)。 +type RemoteLibraryView struct { + Library model.Library + MountID string + AccountID string + RemoteID string + CollectionType string + AccountName string +} + +// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表 +// (只有显式挂载的库才出现在本项目媒体库中)。 +func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) { + mounts, err := r.ListMounts(ctx) + if err != nil || len(mounts) == 0 { + return nil, err + } + // 按账号分组,每账号拉一次 Views 做匹配。 + byAccount := map[string][]*model.EmbyMount{} + for i := range mounts { + m := mounts[i] + if !m.Enabled { + continue + } + byAccount[m.AccountID] = append(byAccount[m.AccountID], &mounts[i]) + } + out := make([]RemoteLibraryView, 0, len(mounts)) + for accountID, accountMounts := range byAccount { + acct := r.AccountByID(ctx, accountID) + if acct == nil { + continue + } + cfg, cfgErr := r.configOf(acct) + if cfgErr != nil { + continue + } + views, viewErr := r.RemoteViews(ctx, acct) + if viewErr != nil { + if r.log != nil { + r.log.Warn("web remote emby views failed", + zap.String("account", acct.Name), zap.Error(viewErr)) + } + continue + } + viewByName := map[string]map[string]any{} + for _, v := range views { + viewByName[remoteItemString(v, "Id")] = v + } + for _, mount := range accountMounts { + v, ok := viewByName[mount.RemoteViewID] + if !ok { + continue // 远程已删除该媒体库 + } + lib := r.mapRemoteMountToLibrary(mount, acct, cfg, v) + if lib == nil { + continue + } + out = append(out, RemoteLibraryView{ + Library: *lib, + MountID: mount.ID, + AccountID: acct.ID, + RemoteID: mount.RemoteViewID, + CollectionType: mount.CollectionType, + AccountName: acct.Name, + }) + } + } + return out, nil +} + +// RemoteLibraryByID 按伪装 ID 查远程库视图(详情接口用)。 +func (r *EmbyRemoteService) RemoteLibraryByID(ctx context.Context, mountID, remoteViewID string) (*RemoteLibraryView, error) { + views, err := r.RemoteLibraries(ctx) + if err != nil { + return nil, err + } + for _, v := range views { + if v.MountID == mountID && v.RemoteID == remoteViewID { + cp := v + return &cp, nil + } + } + return nil, nil +} + +// mapRemoteMountToLibrary 把挂载信息 + 远程 View item 映射为网页库结构。 +func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) *model.Library { + if mount == nil { + return nil + } + name := strings.TrimSpace(mount.Name) + if name == "" { + name = strings.TrimSpace(remoteItemString(item, "Name")) + } + if name == "" { + name = acct.Name + } else if !strings.Contains(name, acct.Name) { + name = acct.Name + " · " + name + } + libType := "movie" + switch mount.CollectionType { + case "tvshows": + libType = "tv" + case "music": + libType = "music" + } + lib := &model.Library{ + Base: model.Base{ID: EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)}, + Name: name, + Type: libType, + Enabled: true, + SortOrder: 1000, // 远程库排在本地库之后 + } + // 远程媒体库封面只有真实存在图片标签才下发。 + if remoteItemHasImageTag(item, "Primary") { + lib.CoverURL = r.remoteItemImageURL(cfg, mount.RemoteViewID, "Primary") + } + return lib +} + +// MapRemoteItemToMedia 把远程 Emby item JSON 映射为本地 Media 结构。 +// poster/backdrop 只有在远程确实存在图片标签时才填 URL(避免对无图条目 +// 发出必失败的图片请求导致前端破图);剧集回退到系列海报(SeriesPrimaryImage)。 +func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) model.Media { + encodeScope := acct.ID + if mount != nil { + encodeScope = mount.ID + } + remoteID := remoteItemString(item, "Id") + // 条目可能已被 RewriteEmbyRemoteIDs 伪装(图片/嵌套 ID 需要原始远程 ID)。 + if _, rid, ok := DecodeEmbyRemoteID(remoteID); ok { + remoteID = rid + } + seriesID := remoteItemString(item, "SeriesId") + if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok { + seriesID = rid + } + media := model.Media{ + Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)}, + Title: remoteItemString(item, "Name"), + OriginalName: remoteItemString(item, "OriginalTitle"), + Overview: remoteItemString(item, "Overview"), + Year: remoteItemInt(item, "ProductionYear"), + Rating: float32(remoteItemFloat(item, "CommunityRating")), + Path: remoteItemString(item, "Path"), + Genres: remoteItemGenres(item), + ScrapeStatus: "done", + } + // 只有远程明确存在图片标签才下发图片 URL。 + if remoteItemHasImageTag(item, "Primary") { + media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary") + } + if remoteItemHasImageTag(item, "Backdrop") || len(remoteBackdropTags(item)) > 0 { + media.BackdropURL = r.remoteItemImageURL(cfg, remoteID, "Backdrop") + } + if ticks := remoteItemInt64(item, "RunTimeTicks"); ticks > 0 { + media.DurationSec = int(ticks / 10_000_000) + } + if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok { + media.ReleaseDate = date.Format("2006-01-02") + } + if providerIDs, ok := item["ProviderIds"].(map[string]any); ok { + if v := anyString(providerIDs["Tmdb"]); v != "" { + media.TMDbID, _ = strconv.Atoi(v) + } + if v := anyString(providerIDs["Imdb"]); v != "" { + media.TheTVDBID = v + } + if v := anyString(providerIDs["Douban"]); v != "" { + media.DoubanID = v + } + } + switch remoteItemString(item, "Type") { + case "Episode": + media.SeasonNum = remoteItemInt(item, "ParentIndexNumber") + media.EpisodeNum = remoteItemInt(item, "IndexNumber") + media.EpisodeTitle = remoteItemString(item, "Name") + if seriesName := remoteItemString(item, "SeriesName"); seriesName != "" { + media.Title = seriesName + } + // 单集通常无独立海报:若远程返回 SeriesPrimaryImageTag(需要 + // Fields=SeriesPrimaryImage)且系列有图,则回退到系列海报。 + if media.PosterURL == "" && seriesID != "" && + strings.TrimSpace(remoteItemString(item, "SeriesPrimaryImageTag")) != "" { + media.PosterURL = r.remoteItemImageURL(cfg, seriesID, "Primary") + } + default: // Movie / Series / Season / Folder + media.SeasonNum = 0 + media.EpisodeNum = 0 + } + return media +} + +// RemoteLibraryMedia 拉远程库直属条目(电影库=Movie,剧集库=Series),映射分页。 +func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, itemTypes string, offset, limit int) ([]model.Media, int64, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, 0, err + } + if itemTypes == "" { + itemTypes = "Movie,Series" // 未知类型时两者都取(前端自行按 episode-like 分组) + } + q := url.Values{} + q.Set("ParentId", remoteViewID) + q.Set("IncludeItemTypes", itemTypes) + q.Set("Recursive", "false") + q.Set("StartIndex", strconv.Itoa(offset)) + q.Set("Limit", strconv.Itoa(limit)) + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage") + var body struct { + Items []map[string]any `json:"Items"` + TotalRecordCount int64 `json:"TotalRecordCount"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { + return nil, 0, err + } + items := make([]model.Media, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, mount.ID) // 嵌套/关联 ID 一并伪装 + items = append(items, r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)) + } + return items, body.TotalRecordCount, nil +} + +// RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。 +func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID) + path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path" + var out map[string]any + if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil { + return nil, err + } + RewriteEmbyRemoteIDs(out, mount.ID) + m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out) + return &m, nil +} + +// RemoteEpisodes 拉远程条目下的集列表(Series/Season/Folder→子集;Episode→同系列; +// Movie→自身单条),按季/集排序,与本地 ListMediaEpisodes 行为一致。 +func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) ([]model.Media, error) { + detail, err := r.RemoteMediaDetail(ctx, mount, acct, remoteID) + if err != nil { + return nil, err + } + // 用远程详情载荷精判类型(Episode→同系列;Series/Season/Folder→子集;Movie→单条)。 + itemType := r.remoteItemType(ctx, acct, remoteID) + if itemType == "" { + itemType = remoteItemTypeOf(detail) + } + var parentID string + switch itemType { + case "Episode": + parentID = r.remoteItemSeriesID(ctx, acct, remoteID) + if parentID == "" { + parentID = remoteID + } + case "Season", "Folder", "Series": + parentID = remoteID + default: // Movie + return []model.Media{*detail}, nil + } + rows, _, err := r.remoteEpisodesOf(ctx, mount, acct, parentID) + if err != nil { + return nil, err + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].SeasonNum != rows[j].SeasonNum { + return rows[i].SeasonNum < rows[j].SeasonNum + } + if rows[i].EpisodeNum != rows[j].EpisodeNum { + return rows[i].EpisodeNum < rows[j].EpisodeNum + } + return rows[i].Title < rows[j].Title + }) + return rows, nil +} + +func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string) ([]model.Media, int64, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, 0, err + } + q := url.Values{} + q.Set("ParentId", parentID) + q.Set("IncludeItemTypes", "Episode") + q.Set("Recursive", "true") + q.Set("StartIndex", "0") + q.Set("Limit", "500") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage") + var body struct { + Items []map[string]any `json:"Items"` + TotalRecordCount int64 `json:"TotalRecordCount"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { + return nil, 0, err + } + items := make([]model.Media, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, mount.ID) + m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) + items = append(items, m) + } + return items, body.TotalRecordCount, nil +} + +// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。 +func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("ParentId", remoteViewID) + q.Set("IncludeItemTypes", "Series") + q.Set("Recursive", "false") + q.Set("StartIndex", "0") + q.Set("Limit", "1000") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage") + var body struct { + Items []map[string]any `json:"Items"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { + return nil, err + } + cards := make([]SeriesCard, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, mount.ID) + m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) + // 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。 + count := remoteItemInt(it, "RecursiveItemCount") + if count == 0 { + count = remoteItemInt(it, "ChildCount") + } + if count == 0 { + count = 1 + } + cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count}) + } + return cards, nil +} + +// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。 +func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit) + if err != nil { + return nil, err + } + cards := make([]SeriesCard, 0, len(items)) + for _, it := range items { + m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) + cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0}) + } + return cards, nil +} + +// WebStreamURL 远程条目的网页播放地址(302 直连远程 Emby 流端点)。 +func (r *EmbyRemoteService) WebStreamURL(ctx context.Context, acct *model.StrmAccount, remoteID string) (string, error) { + cfg, err := r.configOf(acct) + if err != nil { + return "", err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return "", err + } + return r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + + "/stream?api_key=" + url.QueryEscape(cfg.Token) + "&Static=true", nil +} + +// remoteItemType 轻量查询远程条目 Type(避免依赖映射载荷)。 +func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.StrmAccount, remoteID string) string { + cfg, err := r.configOf(acct) + if err != nil { + return "" + } + var out map[string]any + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items/"+url.PathEscape(remoteID), nil, &out); err != nil { + return "" + } + return remoteItemString(out, "Type") +} + +// remoteItemSeriesID 轻量查询 Episode 的 SeriesId。 +func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.StrmAccount, remoteID string) string { + cfg, err := r.configOf(acct) + if err != nil { + return "" + } + var out map[string]any + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items/"+url.PathEscape(remoteID), nil, &out); err != nil { + return "" + } + return remoteItemString(out, "SeriesId") +} + +// ─── 远程 item JSON 取值辅助 ──────────────────────────────────────────────── + +func anyString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func remoteItemString(item map[string]any, key string) string { + if item == nil { + return "" + } + if s, ok := item[key].(string); ok { + return s + } + return "" +} + +func remoteItemInt(item map[string]any, key string) int { + if item == nil { + return 0 + } + switch v := item[key].(type) { + case float64: + return int(v) + case int: + return v + case string: + n, _ := strconv.Atoi(v) + return n + } + return 0 +} + +func remoteItemInt64(item map[string]any, key string) int64 { + if item == nil { + return 0 + } + switch v := item[key].(type) { + case float64: + return int64(v) + case int: + return int64(v) + case string: + n, _ := strconv.ParseInt(v, 10, 64) + return n + } + return 0 +} + +func remoteItemFloat(item map[string]any, key string) float64 { + if item == nil { + return 0 + } + switch v := item[key].(type) { + case float64: + return v + case int: + return float64(v) + case string: + f, _ := strconv.ParseFloat(v, 64) + return f + } + return 0 +} + +// remoteItemGenres 合并 GenreItems / Genres 数组为逗号分隔字符串(前端 parseCSV 消费)。 +func remoteItemGenres(item map[string]any) string { + seen := map[string]bool{} + var parts []string + collect := func(arr any) { + list, ok := arr.([]any) + if !ok { + return + } + for _, it := range list { + var name string + if m, isMap := it.(map[string]any); isMap { + name = remoteItemString(m, "Name") + } else if s, isStr := it.(string); isStr { + name = s + } + if name != "" && !seen[name] { + seen[name] = true + parts = append(parts, name) + } + } + } + collect(item["GenreItems"]) + collect(item["Genres"]) + return strings.Join(parts, ",") +} + +// remoteItemImageURL 构造远程条目图片绝对地址(带 api_key;前端经 /api/img 代理)。 +func (r *EmbyRemoteService) remoteItemImageURL(cfg *EmbyRemoteConfig, remoteID, imageType string) string { + if remoteID == "" { + return "" + } + imageType = strings.ToLower(imageType) + if imageType == "" { + imageType = "primary" + } + return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(imageType) + + "?api_key=" + url.QueryEscape(cfg.Token) +} + +// remoteItemHasImageTag 远程 item 是否带某类型图片标签(Emby 的 ImageTags map)。 +func remoteItemHasImageTag(item map[string]any, typ string) bool { + if item == nil { + return false + } + switch tags := item["ImageTags"].(type) { + case map[string]any: + _, ok := tags[typ] + return ok + case map[string]string: + _, ok := tags[typ] + return ok + } + return false +} + +// remoteBackdropTags 远程 item 的 BackdropImageTags 数组。 +func remoteBackdropTags(item map[string]any) []any { + if item == nil { + return nil + } + switch tags := item["BackdropImageTags"].(type) { + case []any: + return tags + case []string: + out := make([]any, 0, len(tags)) + for _, s := range tags { + out = append(out, s) + } + return out + } + return nil +} + +// remoteItemTypeOf 从映射后的 Media 推断远程类型(无详情载荷时兜底)。 +func remoteItemTypeOf(m *model.Media) string { + if m == nil { + return "Movie" + } + if m.EpisodeNum > 0 || m.SeasonNum > 0 { + return "Episode" + } + return "Movie" +} +// ─── 供 handler 层使用的远程 View 条目取值(导出薄封装) ────────────────────── + +// RemoteItemIDString 提取远程 View 条目的 Id。 +func RemoteItemIDString(item map[string]any) string { return remoteItemString(item, "Id") } + +// RemoteItemNameString 提取远程 View 条目的 Name。 +func RemoteItemNameString(item map[string]any) string { return remoteItemString(item, "Name") } + +// RemoteItemCollectionType 提取远程 View 条目的 CollectionType。 +func RemoteItemCollectionType(item map[string]any) string { return remoteItemString(item, "CollectionType") } + +// RemoteItemChildCount 提取远程 View 条目的 ChildCount。 +func RemoteItemChildCount(item map[string]any) int { return remoteItemInt(item, "ChildCount") } diff --git a/internal/service/emby_system.go b/internal/service/emby_system.go index eeeaff8..a3f92b4 100644 --- a/internal/service/emby_system.go +++ b/internal/service/emby_system.go @@ -124,7 +124,8 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any { } } -// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。 +// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目,外加所有启用的 +// 远程 Emby 挂载的媒体库(联邦聚合)。 func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) { libs, err := e.repo.Library.List(ctx) if err != nil { @@ -132,16 +133,92 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, } libs = FilterDisplayCloudLibraries(ctx, e.repo, libs) visibility := e.mediaVisibility(ctx, userID) - items := make([]map[string]any, 0, len(libs)) + items := make([]map[string]any, 0, len(libs)+4) for _, l := range libs { if !e.libraryVisibleFromCachedVisibility(l, visibility) { continue } items = append(items, e.libraryAsView(ctx, &l)) } + for _, remote := range e.remoteViews(ctx) { + items = append(items, remote) + } return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil } +// remoteViews 返回全部启用挂载的远程媒体库视图(只有显式挂载的库才出现)。 +func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any { + if e == nil || e.remote == nil { + return nil + } + views, err := e.remote.RemoteLibraries(ctx) + if err != nil || len(views) == 0 { + return nil + } + out := make([]map[string]any, 0, len(views)) + for _, v := range views { + out = append(out, remoteLibraryViewPayload(v)) + } + return out +} + +// remoteLibraryViewPayload 把挂载库展示信息标准化为 Emby View payload +// (ID 用挂载伪装,名称=挂载显示名)。 +func remoteLibraryViewPayload(v RemoteLibraryView) map[string]any { + encoded := EncodeEmbyRemoteID(v.MountID, v.RemoteID) + collectionType := v.CollectionType + if !isSupportedEmbyCollectionType(collectionType) { + collectionType = "mixed" + } + name := strings.TrimSpace(v.Library.Name) + imageTags := map[string]string{} + if strings.TrimSpace(v.Library.CoverURL) != "" { + imageTags["Primary"] = encoded + } + return map[string]any{ + "Id": encoded, + "Name": name, + "CollectionType": collectionType, + "ServerId": embyServerID, + "Type": "CollectionFolder", + "IsFolder": true, + "Path": "", + "SortName": strings.ToLower(name), + "DateCreated": time.Now().UTC().Format(time.RFC3339), + "CanDelete": false, + "CanDownload": false, + "DisplayPreferencesId": encoded, + "PrimaryImageItemId": encoded, + "PrimaryImageAspectRatio": 1.7777777777777777, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SpecialFeatureCount": 0, + "EnableMediaSourceDisplay": true, + "PlayAccess": "Full", + "ExternalUrls": []any{}, + "ProviderIds": map[string]string{}, + "Genres": []string{}, + "Tags": []string{}, + "ImageTags": imageTags, + "BackdropImageTags": []string{}, + "UserData": map[string]any{ + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "UnplayedItemCount": 0, + }, + } +} + +func isSupportedEmbyCollectionType(t string) bool { + switch t { + case "movies", "tvshows", "music", "mixed", "homevideos", "boxsets": + return true + } + return false +} + func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any { collectionType := "movies" switch l.Type { diff --git a/internal/service/emby_user_data.go b/internal/service/emby_user_data.go index 831d5b4..c656f85 100644 --- a/internal/service/emby_user_data.go +++ b/internal/service/emby_user_data.go @@ -12,8 +12,16 @@ import ( "github.com/ShukeBta/MMTL/internal/model" ) -// SetFavorite 把 mediaID 标为 userID 的收藏。 +// SetFavorite 把 mediaID 标为 userID 的收藏。远程 Emby 条目直接透传到对应 +// 服务器(本地不落库)。 func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error { + if e.remote != nil && IsEmbyRemoteID(mediaID) { + acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID) + if err := e.ProxyRemoteSetFavorite(ctx, acctID, remoteID, favorite); err != nil { + return err + } + return nil + } if favorite { var f model.Favorite err := e.repo.DB.WithContext(ctx). @@ -31,7 +39,15 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f } // MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。 +// 远程 Emby 条目直接透传到对应服务器(本地不落库)。 func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error { + if e.remote != nil && IsEmbyRemoteID(mediaID) { + acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID) + if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil { + return err + } + return nil + } if !played { return e.repo.DB.WithContext(ctx). Where("user_id = ? AND media_id = ?", userID, mediaID). diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index 2500645..1dfa893 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -2,8 +2,8 @@ package service import ( "errors" + "io" "net/http" - "net/http/httptest" "path/filepath" "strings" "testing" @@ -396,15 +396,19 @@ func TestEnrichOneAdultScrapesArtwork(t *testing.T) { t.Fatal(err) } - // Mock adult provider - adultServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(`
连接远程 Emby 服务器(Jellyfin 暂不支持)
++ 「{account.name}」上的远程媒体库,勾选后出现在本项目媒体库;未勾选的不挂载 +
+远程服务器没有可挂载的媒体库
+ ) : ( + views.map((v) => { + const checked = selected[v.remote_view_id] + const disabled = v.already_mounted + return ( +{v.remote_view_name}
++ {v.collection_type || 'mixed'} + {v.child_count > 0 ? ` · ${v.child_count} 条目` : ''} + {disabled ? ' · 已挂载' : ''} +
++ 添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理 +
+还没有 Emby 服务器
+点击右上角「添加 Emby 账号」,填入服务器地址与凭据后即可挂载媒体库
+{acct.name}
++ {acct.has_credential ? '凭据已配置' : '待补全凭据'} + {acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''} +
++ 尚未挂载媒体库 —— 点击下方「选择挂载」,或一键全量挂载该服务器的全部媒体库 +
+ ) : ( ++ {m.name || m.remote_view_name || '未命名'} +
++ {m.collection_type || 'mixed'} + {m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'} +
+