Merge pull request #1 from truewhile/beta

添加emby挂载功能,整合上传,下载,刮削队列到任务队列
This commit is contained in:
truewhile
2026-09-01 14:33:38 +08:00
committed by GitHub
50 changed files with 4093 additions and 71 deletions
+133
View File
@@ -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
+5
View File
@@ -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
}
+198
View File
@@ -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})
}
}
+37 -5
View File
@@ -34,10 +34,19 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
// embySubtitleStreamHandler serves an external subtitle track advertised in a
// MediaSource's MediaStreams via its Emby index
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
// sideloaded subtitle file next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams.
// sideloaded subtitle track next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams. 远程 Emby
// 条目的字幕直接反向代理远程。
func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteSubtitle(c.Request.Context(), c.Writer, c.Request, accountID, remoteID, c.Param("index")); err != nil {
embyError(c, http.StatusNotFound, "subtitle not found")
return
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
@@ -213,12 +222,26 @@ func embyAppendAPIKey(raw, token string) string {
return u.String()
}
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口。
// 远程 Emby 条目(embyremote~ 前缀)走反向代理;本地条目直接代理到
// /api/stream/{id}(同一个 ServeFile)。
func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteVideoStream(c.Request.Context(), c.Writer, c.Request, accountID, remoteID); err != nil {
if errors.Is(err, service.ErrEmbyRemoteNotFound) {
c.Status(http.StatusNotFound)
return
}
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
item, err := svc.Emby.Item(c.Request.Context(), encodedID, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -296,6 +319,11 @@ func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container,
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 远程 Emby 条目不做本地转码(播放地址已由 PlaybackInfo 指向远程/代理直连)。
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
@@ -319,6 +347,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
+162 -15
View File
@@ -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
+8
View File
@@ -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))
+85 -3
View File
@@ -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
+15 -7
View File
@@ -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])
}
}
+19
View File
@@ -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"` // 是否在媒体库中展示
}
+1
View File
@@ -58,5 +58,6 @@ func AllModels() []interface{} {
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&EmbyMount{},
}
}
+1
View File
@@ -13,6 +13,7 @@ const (
StrmProviderCloudDrive = "clouddrive2" // CloudDrive2(WebDAV 桥接)
StrmProviderOpenList = "openlist" // OpenList / AList 兼容桥接
StrmProviderLocal = "local" // 本地目录(无账号)
StrmProviderEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关聚合挂载,不走 STRM 同步)
)
// StrmAccount 是一个网盘账号(STRM 同步数据源凭据)。
@@ -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
}
+2
View File
@@ -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},
}
}
+4 -1
View File
@@ -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.
+246
View File
@@ -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
}
+136
View File
@@ -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")
}
}
+9
View File
@@ -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":
+109 -1
View File
@@ -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}
}
+18 -1
View File
@@ -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) {
+83
View File
@@ -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
+862
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+102
View File
@@ -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"])
}
}
@@ -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)
}
}
+587
View File
@@ -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") }
+79 -2
View File
@@ -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 {
+17 -1
View File
@@ -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).
+20 -10
View File
@@ -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(`<html><h3>IPX-235 测试番号封面</h3><a class="bigImage" href="/pics/cover/ipx235.jpg"></a></html>`))
}))
defer adultServer.Close()
// Mock adult provider:RoundTripper 拦截 provider 发出的全部请求(不访问
// 外网),任何站点都返回带封面链接的测试 HTML——保证 CI 上不命中真实
// javdb/dmm 站点(外网可达时会把真实封面写进断言导致失败)。
adultHTML := `<html><h3>IPX-235 测试番号封面</h3><a class="bigImage" href="/pics/cover/ipx235.jpg"></a></html>`
mockAdultTransport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: io.NopCloser(strings.NewReader(adultHTML)),
}, nil
})
scraper.adult = &AdultProvider{
client: adultServer.Client(),
client: &http.Client{Transport: mockAdultTransport},
}
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
@@ -418,7 +422,13 @@ func TestEnrichOneAdultScrapesArtwork(t *testing.T) {
if got.ScrapeStatus != "matched" {
t.Fatalf("ScrapeStatus = %q, want 'matched'", got.ScrapeStatus)
}
if got.PosterURL != "https://pics.dmm.co.jp/digital/video/ipx00235/ipx00235pl.jpg" && got.PosterURL != adultServer.URL+"/pics/cover/ipx235.jpg" {
// 封面来自 mock HTML 的 cover 链接(Host 随站点变化,只校验路径后缀)。
if got.PosterURL != "https://pics.dmm.co.jp/digital/video/ipx00235/ipx00235pl.jpg" && !strings.HasSuffix(got.PosterURL, "/pics/cover/ipx235.jpg") {
t.Fatalf("unexpected PosterURL = %q", got.PosterURL)
}
}
// roundTripFunc 把函数适配为 http.RoundTripper,用于测试中拦截全部外网请求。
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+6
View File
@@ -46,6 +46,7 @@ type Container struct {
Scheduler *SchedulerService
Storage *StorageService
Emby *EmbyService
EmbyRemote *EmbyRemoteService
Backup *BackupService
PlayProfiles *PlayProfileService
Permissions *PermissionService
@@ -99,6 +100,11 @@ func (c *Container) Boot() {
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
// 远程 Emby 挂载兼容迁移:旧账号无挂载时自动全量挂载
if c.EmbyRemote != nil {
c.EmbyRemote.AutoSeedMounts(c.stopCtx)
}
// STRM 元数据下载/上传队列与定时同步巡检
if c.Strm != nil {
c.Strm.Start(c.stopCtx)
+2
View File
@@ -103,6 +103,8 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.DLNA = NewDLNAService(b.log)
b.c.Storage = NewStorageService(b.log, b.repos)
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos)
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto)
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
+46 -2
View File
@@ -73,7 +73,7 @@ var StrmSettingDefs = map[string]struct {
}
// StrmAccountSecretKeys 是账号配置中需要加密存储的字段。
var StrmAccountSecretKeys = []string{"cookie", "password", "token", "access_token", "refresh_token"}
var StrmAccountSecretKeys = []string{"cookie", "password", "token", "access_token", "refresh_token", "api_key"}
// StrmService 提供 STRM 管理的能力。
type StrmService struct {
@@ -237,6 +237,33 @@ func (s *StrmService) strmAccountConfig(acct *model.StrmAccount) (map[string]str
return cfg, nil
}
// mergeEmbyRemoteConfig 对远程 Emby 账号配置做合并式更新:config 中出现的键
// 覆盖写入(敏感键按明文加密),未出现的键保留原密文;显式空字符串=清除。
func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]string) (string, error) {
out := map[string]string{}
if strings.TrimSpace(existing) != "" {
if err := json.Unmarshal([]byte(existing), &out); err != nil {
return "", fmt.Errorf("decode account config: %w", err)
}
}
for k, v := range config {
if v == "" {
delete(out, k)
continue
}
if strmContains(StrmAccountSecretKeys, k) {
out[k] = s.crypto.Encrypt(v)
} else {
out[k] = v
}
}
data, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(data), nil
}
// HasStrmAccountCredential 报告账号是否已配置核心凭据(用于前端展示)。
func HasStrmAccountCredential(acct *model.StrmAccount) bool {
switch acct.Provider {
@@ -245,6 +272,11 @@ func HasStrmAccountCredential(acct *model.StrmAccount) bool {
return strings.Contains(acct.Config, `"access_token"`)
case model.StrmProviderOpenList:
return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`)
case model.StrmProviderEmbyRemote:
// 远程 Emby:接入地址 + (自动认证凭据 或 手动 api_key) 即视为已配置。
return strings.Contains(acct.Config, `"url"`) &&
(strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"api_key"`) ||
(strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`)))
default:
return strings.Contains(acct.Config, `"password"`) || strings.Contains(acct.Config, `"token"`)
}
@@ -288,7 +320,16 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
acct.Enabled = *enabled
}
if len(config) > 0 {
enc, err := s.strmAccountConfigJSON(config, true)
var enc string
var err error
if acct.Provider == model.StrmProviderEmbyRemote {
// 远程 Emby 账号:合并式更新。config 中出现的键覆盖(敏感键按明文
// 加密写入),未出现的键保留原密文——避免编辑「代理开关」时把已
// 保存的地址与凭据清空。
enc, err = s.mergeEmbyRemoteConfig(acct.Config, config)
} else {
enc, err = s.strmAccountConfigJSON(config, true)
}
if err != nil {
return nil, err
}
@@ -649,6 +690,8 @@ func providerLabel(provider string) string {
return "OpenList"
case model.StrmProviderLocal:
return "本地目录"
case model.StrmProviderEmbyRemote:
return "Emby 远程挂载"
default:
return provider
}
@@ -660,6 +703,7 @@ var StrmProviderLabels = map[string]string{
model.StrmProviderCloudDrive: "CloudDrive2",
model.StrmProviderOpenList: "OpenList",
model.StrmProviderLocal: "本地目录",
model.StrmProviderEmbyRemote: "Emby 远程挂载",
}
const defaultStrmUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-Strm/1.0"
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
@@ -291,8 +292,15 @@ func TestSanitizePathWithSpecialChars(t *testing.T) {
}
// Test sanitizeLocalPath - Windows Drive
// sanitizeLocalPath 的盘符处理是平台相关的:Windows 保留 `D:\` 前缀,
// Linux 把反斜杠统一当作分隔符(结果等价于 D:/test/...)。
localWin := `D:\test\动漫\数码宝贝:拯救者\poster.jpg`
wantLocalWin := filepath.Join(`D:\`, "test", "动漫", "数码宝贝 拯救者", "poster.jpg")
var wantLocalWin string
if runtime.GOOS == "windows" {
wantLocalWin = filepath.Join(`D:\`, "test", "动漫", "数码宝贝 拯救者", "poster.jpg")
} else {
wantLocalWin = filepath.Join("D", "test", "动漫", "数码宝贝 拯救者", "poster.jpg")
}
if got := sanitizeLocalPath(localWin); got != wantLocalWin {
t.Errorf("sanitizeLocalPath(%q) = %q, want %q", localWin, got, wantLocalWin)
}
+19
View File
@@ -0,0 +1,19 @@
import { api } from './client'
import type { EmbyMount, EmbyMountInput, RemoteEmbyView } from '../types/emby'
export const embyAPI = {
listMounts: () => api.get<EmbyMount[]>('/admin/emby/mounts').then((r) => r.data),
createMounts: (payload: EmbyMountInput) =>
api.post<{ ok: boolean; created: number }>('/admin/emby/mounts', payload).then((r) => r.data),
updateMount: (id: string, payload: { name?: string; proxy_play?: boolean; enabled?: boolean }) =>
api.put<EmbyMount>(`/admin/emby/mounts/${id}`, payload).then((r) => r.data),
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
listAccountViews: (accountId: string) =>
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
fullMountAccount: (accountId: string, proxy: boolean) =>
api
.post<{ ok: boolean; created: number }>(`/admin/emby/accounts/${accountId}/full-mount`, null, {
params: { proxy: proxy ? 1 : 0 },
})
.then((r) => r.data),
}
+5 -3
View File
@@ -26,6 +26,7 @@ const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
)
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
const EmbyMountPage = lazy(() => import('./pages/EmbyMountPage').then((m) => ({ default: m.EmbyMountPage })))
const StrmManagePage = lazy(() => import('./pages/StrmManagePage').then((m) => ({ default: m.StrmManagePage })))
const StrmDownloadQueuePage = lazy(() =>
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmDownloadQueuePage })),
@@ -33,9 +34,8 @@ const StrmDownloadQueuePage = lazy(() =>
const StrmUploadQueuePage = lazy(() =>
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
)
const ScraperQueuePage = lazy(() =>
import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })),
)
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
export type AppRoute = {
path?: string
@@ -62,9 +62,11 @@ export const appRoutes: AppRoute[] = [
{ path: 'tools', element: <Navigate to="/files" replace /> },
{ path: 'files', element: <FileManagerPage />, adminOnly: true },
{ path: 'settings', element: <SettingsPage />, adminOnly: true },
{ path: 'emby-mount', element: <EmbyMountPage />, adminOnly: true },
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
{ path: 'queue', element: <TaskQueuePage />, adminOnly: true },
{ path: 'admin', element: <AdminPage />, adminOnly: true },
]
+6 -7
View File
@@ -1,12 +1,11 @@
import type { LucideIcon } from 'lucide-react'
import {
Download,
FileOutput,
FolderOpen,
Library,
ListChecks,
Settings,
Sparkles,
Upload,
Tv,
User,
Users,
} from 'lucide-react'
@@ -23,12 +22,12 @@ export type LayoutNavItem = {
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/profile', label: '个人资料', icon: User },
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
{ to: '/scraper/queue', label: '刮削队列', icon: Sparkles, adminOnly: true },
{ to: '/queue', label: '任务队列', icon: ListChecks, adminOnly: true },
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
// STRM 管理(设置区):网盘目录生成 strm 与元数据下载/上传队列
// Emby 挂载(设置区):远程 Emby 媒体库挂载
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
// STRM 管理(设置区):网盘目录生成 strm 与元数据同步
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
{ to: '/strm/downloads', label: '下载队列', icon: Download, adminOnly: true },
{ to: '/strm/uploads', label: '上传队列', icon: Upload, adminOnly: true },
]
+518
View File
@@ -0,0 +1,518 @@
/* eslint-disable react-refresh/only-export-components */
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
Check,
Globe,
Loader2,
Pencil,
Plus,
Power,
PowerOff,
RefreshCw,
Server,
Trash2,
Tv,
X,
} from 'lucide-react'
import { strmAPI } from '../api/strm'
import { embyAPI } from '../api/emby'
import type { StrmAccount } from '../types/strm'
import type { EmbyMount, RemoteEmbyView } from '../types/emby'
import { apiErrorMessage } from './StrmManagePage'
import { confirmAction } from '../components/confirmAction'
const inputCls = 'input-base w-full'
// ─── 账号对话框(Emby 服务器连接) ─────────────────────────────────────────────
function AccountDialog({
existing,
onClose,
onSaved,
}: {
existing: StrmAccount | null
onClose: () => void
onSaved: () => void
}) {
const [name, setName] = useState(existing?.name ?? '')
const [url, setUrl] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
const [saving, setSaving] = useState(false)
const canSave = () => url !== '' && (token !== '' || password !== '')
const submit = async (event: React.FormEvent) => {
event.preventDefault()
setSaving(true)
try {
const config: Record<string, string> = {
...(url ? { url } : {}),
...(username ? { username } : {}),
...(password ? { password } : {}),
...(token ? { token } : {}),
}
if (existing) {
await strmAPI.updateAccount(existing.id, {
name: name || existing.name,
provider: existing.provider,
enabled,
config: Object.keys(config).length ? config : {},
})
toast.success('Emby 账号已更新')
} else {
if (!canSave()) {
toast.error('请填写 Emby 地址与密码(或 API Key)')
return
}
await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config })
toast.success('Emby 账号已添加')
}
onSaved()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="w-full max-w-md rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-5 flex items-center justify-between">
<div>
<h3 className="font-display text-xl font-bold text-ink-600">{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}</h3>
<p className="text-xs text-sand-500">连接远程 Emby 服务器(Jellyfin 暂不支持)</p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
<X size={18} />
</button>
</div>
<form onSubmit={submit} className="space-y-4">
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">账号名称</span>
<input className={inputCls} value={name} placeholder="例如:家庭影院" onChange={(e) => setName(e.target.value)} />
</label>
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">Emby 服务地址</span>
<input
className={inputCls}
value={url}
placeholder="http://192.168.1.10:8096"
onChange={(e) => setUrl(e.target.value)}
disabled={!!existing}
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
</label>
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">密码</span>
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</label>
</div>
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">API Key(可选)</span>
<input
className={inputCls}
value={token}
placeholder="留空则用用户名/密码自动认证"
onChange={(e) => setToken(e.target.value)}
/>
</label>
{existing && (
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
启用该账号
</label>
)}
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
取消
</button>
<button type="submit" disabled={saving} className="neon-button disabled:opacity-50">
{saving ? <Loader2 size={16} className="animate-spin" /> : <Server size={16} />}
保存
</button>
</div>
</form>
</div>
</div>
)
}
// ─── 添加挂载对话框 ────────────────────────────────────────────────────────────
function MountDialog({
account,
onClose,
onSaved,
}: {
account: StrmAccount
onClose: () => void
onSaved: () => void
}) {
const [views, setViews] = useState<RemoteEmbyView[]>([])
const [selected, setSelected] = useState<Record<string, boolean>>({})
const [proxy, setProxy] = useState<Record<string, boolean>>({})
const nameOverride: Record<string, string> = {}
const [allProxy, setAllProxy] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
embyAPI
.listAccountViews(account.id)
.then((rows) => {
setViews(rows)
const init: Record<string, boolean> = {}
rows.forEach((v) => {
if (!v.already_mounted) init[v.remote_view_id] = true
})
setSelected(init)
})
.catch((err) => toast.error(apiErrorMessage(err)))
.finally(() => setLoading(false))
}, [account.id])
const toggleView = (id: string) => setSelected((prev) => ({ ...prev, [id]: !prev[id] }))
const toggleProxy = (id: string) => setProxy((prev) => ({ ...prev, [id]: !prev[id] }))
const submit = async (event: React.FormEvent) => {
event.preventDefault()
const picked = views.filter((v) => selected[v.remote_view_id])
if (picked.length === 0) {
toast.error('请至少选择一个媒体库')
return
}
setSaving(true)
try {
await embyAPI.createMounts({
account_id: account.id,
views: picked.map((v) => ({
remote_view_id: v.remote_view_id,
remote_view_name: v.remote_view_name,
collection_type: v.collection_type,
name: nameOverride[v.remote_view_id] || '',
proxy_play: allProxy || proxy[v.remote_view_id] || false,
})),
})
toast.success(`已挂载 ${picked.length} 个媒体库`)
onSaved()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-display text-xl font-bold text-ink-600">选择要挂载的媒体库</h3>
<p className="text-xs text-sand-500">
「{account.name}」上的远程媒体库,勾选后出现在本项目媒体库;未勾选的不挂载
</p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
<X size={18} />
</button>
</div>
<div className="mb-3 flex items-center justify-between rounded-xl bg-gray-50 px-4 py-2.5 text-sm">
<label className="flex items-center gap-2 font-semibold text-ink-600">
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={allProxy} onChange={(e) => setAllProxy(e.target.checked)} />
全部走本服务器代理播放
</label>
<span className="text-xs text-sand-500">关闭(默认)= 客户端直连远程 Emby 拉流</span>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-sand-500">
<Loader2 size={16} className="animate-spin" /> 正在读取远程媒体库…
</div>
) : views.length === 0 ? (
<p className="py-10 text-center text-sm text-sand-500">远程服务器没有可挂载的媒体库</p>
) : (
views.map((v) => {
const checked = selected[v.remote_view_id]
const disabled = v.already_mounted
return (
<div key={v.remote_view_id} className={`flex items-center gap-3 rounded-xl border px-3.5 py-2.5 ${disabled ? 'border-gray-100 bg-gray-50 opacity-60' : 'border-sand-200 bg-white'}`}>
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={disabled ? false : checked}
disabled={disabled}
onChange={() => toggleView(v.remote_view_id)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink-600">{v.remote_view_name}</p>
<p className="text-xs text-sand-500">
{v.collection_type || 'mixed'}
{v.child_count > 0 ? ` · ${v.child_count} 条目` : ''}
{disabled ? ' · 已挂载' : ''}
</p>
</div>
{!disabled && (
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs font-semibold text-ink-100">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary-400"
checked={proxy[v.remote_view_id] || false}
disabled={allProxy}
onChange={() => toggleProxy(v.remote_view_id)}
/>
代理
</label>
)}
</div>
)
})
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
取消
</button>
<button type="button" onClick={submit} disabled={saving || loading} className="neon-button disabled:opacity-50">
{saving ? <Loader2 size={16} className="animate-spin" /> : <Tv size={16} />}
挂载所选媒体库
</button>
</div>
</div>
</div>
)
}
// ─── 页面主体 ──────────────────────────────────────────────────────────────────
export function EmbyMountPage() {
const [accounts, setAccounts] = useState<StrmAccount[]>([])
const [mounts, setMounts] = useState<EmbyMount[]>([])
const [loading, setLoading] = useState(true)
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
const [testingID, setTestingID] = useState<string | null>(null)
const load = useCallback(async () => {
const [accts, mts] = await Promise.all([
strmAPI.listAccounts().then((rows) => rows.filter((a) => a.provider === 'emby_remote')),
embyAPI.listMounts(),
])
setAccounts(accts)
setMounts(mts)
}, [])
useEffect(() => {
load()
.catch((err) => toast.error(apiErrorMessage(err)))
.finally(() => setLoading(false))
}, [load])
const testAccount = async (acct: StrmAccount) => {
setTestingID(acct.id)
try {
const updated = await strmAPI.testAccount(acct.id)
toast.success(updated.last_test_ok ? `「${updated.name}」连接正常` : `连接失败:${updated.last_test_result}`)
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setTestingID(null)
}
}
const removeAccount = async (acct: StrmAccount) => {
if (!(await confirmAction({ message: `删除 Emby 账号「${acct.name}」?其下 ${mounts.filter((m) => m.account_id === acct.id).length} 个挂载会一并移除。`, confirmText: '删除' }))) return
try {
await strmAPI.deleteAccount(acct.id)
toast.success('账号已删除')
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const fullMount = async (acct: StrmAccount) => {
if (!(await confirmAction({ message: `把「${acct.name}」上的全部媒体库一键挂载(播放默认直连远程)?`, confirmText: '全量挂载' }))) return
try {
const res = await embyAPI.fullMountAccount(acct.id, false)
toast.success(res.created > 0 ? `已挂载全部 ${res.created} 个媒体库` : '没有新的媒体库需要挂载')
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const removeMount = async (mount: EmbyMount) => {
if (!(await confirmAction({ message: `取消挂载「${mount.remote_view_name || mount.name}」?媒体库列表中将不再显示。`, confirmText: '取消挂载' }))) return
try {
await embyAPI.deleteMount(mount.id)
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const toggleMount = async (mount: EmbyMount, enabled: boolean) => {
try {
await embyAPI.updateMount(mount.id, { enabled })
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
const toggleMountProxy = async (mount: EmbyMount, proxy: boolean) => {
try {
await embyAPI.updateMount(mount.id, { proxy_play: proxy })
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
return (
<div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
Emby 挂载
<span className="text-sand-500">
{' '}
({mounts.length} 个媒体库)
</span>
</h1>
<p className="text-sm text-ink-50">
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理
</p>
</div>
<button onClick={() => setAccountDialog('new')} className="neon-button">
<Server size={16} />
添加 Emby 账号
</button>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-sand-500">
<Loader2 size={18} className="animate-spin" /> 加载中…
</div>
) : accounts.length === 0 ? (
<div className="rounded-3xl border border-dashed border-sand-300 bg-white/60 p-14 text-center">
<Globe size={36} className="mx-auto mb-3 text-sand-400" />
<p className="font-semibold text-ink-600">还没有 Emby 服务器</p>
<p className="mt-1 text-sm text-sand-500">点击右上角「添加 Emby 账号」,填入服务器地址与凭据后即可挂载媒体库</p>
</div>
) : (
accounts.map((acct) => {
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
return (
<div key={acct.id} className="overflow-hidden rounded-3xl border border-sand-200 bg-white shadow-card">
{/* 账号头部 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sand-100 bg-gradient-to-r from-brand-50/60 to-transparent px-5 py-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-100 text-brand-600">
<Server size={20} />
</span>
<div>
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
<p className="text-xs text-sand-500">
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => testAccount(acct)} disabled={testingID === acct.id} className="iconButtonCls">
{testingID === acct.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
测试
</button>
<button onClick={() => setAccountDialog(acct)} className="iconButtonCls">
<Pencil size={13} />
编辑
</button>
<button onClick={() => removeAccount(acct)} className="iconButtonCls !text-red-500 hover:!bg-red-50">
<Trash2 size={13} />
删除
</button>
</div>
</div>
{/* 挂载列表 */}
<div className="px-5 py-4">
{acctMounts.length === 0 ? (
<p className="py-6 text-center text-sm text-sand-500">
尚未挂载媒体库 —— 点击下方「选择挂载」,或一键全量挂载该服务器的全部媒体库
</p>
) : (
<div className="space-y-2">
{acctMounts.map((m) => (
<div key={m.id} className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${m.enabled ? 'border-sand-200 bg-gray-50/60' : 'border-gray-100 bg-gray-50 opacity-60'}`}>
<Tv size={16} className="shrink-0 text-brand-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink-600">
{m.name || m.remote_view_name || '未命名'}
</p>
<p className="text-xs text-sand-500">
{m.collection_type || 'mixed'}
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
</p>
</div>
<button
onClick={() => toggleMountProxy(m, !m.proxy_play)}
disabled={!m.enabled}
className="rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-100 disabled:opacity-40"
title="切换播放代理"
>
{m.proxy_play ? '代理中' : '直连'}
</button>
<button
onClick={() => toggleMount(m, !m.enabled)}
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100"
title={m.enabled ? '停用挂载' : '启用挂载'}
>
{m.enabled ? <Power size={14} /> : <PowerOff size={14} />}
</button>
<button onClick={() => removeMount(m)} className="rounded-lg border border-gray-200 p-1.5 text-red-500 hover:bg-red-50" title="取消挂载">
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<button onClick={() => setMountDialogFor(acct)} className="btn-outline">
<Plus size={14} />
选择挂载媒体库
</button>
<button onClick={() => fullMount(acct)} className="btn-outline">
<Check size={14} />
全量挂载
</button>
</div>
</div>
</div>
)
})
)}
{accountDialog && (
<AccountDialog existing={accountDialog === 'new' ? null : accountDialog} onClose={() => setAccountDialog(null)} onSaved={() => { setAccountDialog(null); load() }} />
)}
{mountDialogFor && <MountDialog account={mountDialogFor} onClose={() => setMountDialogFor(null)} onSaved={() => { setMountDialogFor(null); load() }} />}
</div>
)
}
const iconButtonCls =
'inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1 text-xs font-semibold text-ink-100 transition hover:bg-gray-50'
void iconButtonCls
+6
View File
@@ -1,6 +1,7 @@
import { Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
import type { Media } from '../types'
import { isRemoteEmbyID } from '../utils/remoteEmby'
type LibraryMovieActionsProps = {
media: Media
@@ -23,6 +24,11 @@ export function LibraryMovieActions({
}: LibraryMovieActionsProps) {
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
// 远程 Emby 条目为只读视图:刮削/探测/NFO/回收站不适用。
if (isRemoteEmbyID(media.id)) {
return null
}
return (
<>
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
+1 -1
View File
@@ -63,7 +63,7 @@ export function LibraryPageHeader({
onChange={onSortChange}
onReshuffle={onReshuffle}
/>
{isAdmin && (
{isAdmin && !library?.is_remote_emby && (
<>
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
+2 -1
View File
@@ -1,3 +1,4 @@
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { Link } from 'react-router-dom'
import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, Sparkles, Trash2 } from 'lucide-react'
@@ -84,7 +85,7 @@ export function LibrarySeriesDetailHeader({
</div>
)}
{isAdmin && allEpisodes.length > 0 && (
{isAdmin && allEpisodes.length > 0 && !isRemoteEmbyID(series.rep.id) && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
+5
View File
@@ -2,6 +2,7 @@ import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } fro
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import type { Media } from '../types'
import { isRemoteEmbyID } from '../utils/remoteEmby'
type MediaDetailAdminPanelProps = {
media: Media
@@ -28,6 +29,10 @@ export function MediaDetailAdminPanel({
onExportNFO,
onSoftDelete,
}: MediaDetailAdminPanelProps) {
if (isRemoteEmbyID(media.id)) {
return null
}
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
+7 -4
View File
@@ -1,3 +1,4 @@
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
@@ -95,14 +96,16 @@ export function MediaDetailPlaybackActions({
<span>立即播放</span>
</Link>
<Link
{!isRemoteEmbyID(media.id) && (
<Link
to={`/play/${media.id}?mode=hls`}
state={{ from: `/media/${media.id}` }}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
)}
<ExternalPlayerButton mediaId={media.id} />
+61 -2
View File
@@ -10,6 +10,7 @@ import {
QrCode,
RefreshCw,
Search,
Tv,
X,
} from 'lucide-react'
@@ -109,6 +110,7 @@ export function StrmAccountDialog({
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [proxyPlay, setProxyPlay] = useState(existing?.proxy_play ?? false)
const [saving, setSaving] = useState(false)
const buildConfig = (): Record<string, string> => {
@@ -126,6 +128,14 @@ export function StrmAccountDialog({
...(password ? { password } : {}),
...(token ? { token } : {}),
}
case 'emby_remote':
return {
...(url ? { url } : {}),
...(username ? { username } : {}),
...(password ? { password } : {}),
...(token ? { token } : {}),
proxy_play: proxyPlay ? 'true' : 'false',
}
default:
return {}
}
@@ -137,6 +147,8 @@ export function StrmAccountDialog({
return url !== ''
case 'openlist':
return server !== '' && (token !== '' || password !== '')
case 'emby_remote':
return url !== '' && (token !== '' || password !== '')
default:
return false
}
@@ -182,7 +194,14 @@ export function StrmAccountDialog({
{!existing && (
<div className="grid grid-cols-3 gap-3">
{PROVIDER_OPTIONS.map((option) => {
const Icon = option.provider === 'cloud115' ? QrCode : option.provider === 'clouddrive2' ? HardDrive : FolderPlus
const Icon =
option.provider === 'cloud115'
? QrCode
: option.provider === 'clouddrive2'
? HardDrive
: option.provider === 'emby_remote'
? Tv
: FolderPlus
const active = provider === option.provider
return (
<button
@@ -235,7 +254,13 @@ export function StrmAccountDialog({
</Field>
)}
{provider !== 'cloud115' && (
{provider === 'emby_remote' && (
<Field label="Emby 服务地址" hint="远程 Emby 服务器地址,如 http://192.168.1.10:8096">
<input className={inputCls} value={url} placeholder="http://host:8096" onChange={(e) => setUrl(e.target.value)} />
</Field>
)}
{(provider === 'clouddrive2' || provider === 'emby_remote') && (
<div className="grid grid-cols-2 gap-3">
<Field label="用户名">
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
@@ -246,12 +271,46 @@ export function StrmAccountDialog({
</div>
)}
{provider === 'openlist' && (
<div className="grid grid-cols-2 gap-3">
<Field label="用户名">
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
<Field label="密码">
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</Field>
</div>
)}
{provider === 'emby_remote' && (
<Field label="API Key(可选)" hint="留空则用下方用户名/密码自动认证获取">
<input className={inputCls} value={token} placeholder="留空自动认证" onChange={(e) => setToken(e.target.value)} />
</Field>
)}
{provider === 'openlist' && (
<Field label="Token(可选,优先于密码)">
<input className={inputCls} value={token} onChange={(e) => setToken(e.target.value)} />
</Field>
)}
{provider === 'emby_remote' && (
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 accent-primary-400"
checked={proxyPlay}
onChange={(e) => setProxyPlay(e.target.checked)}
/>
<span>
<span className="font-semibold text-ink-600">播放流量经过本服务器代理</span>
<span className="block text-xs text-sand-500">
关闭(推荐):播放时客户端直连远程 Emby,本服务器不参与流量中转
</span>
</span>
</label>
)}
{existing && provider !== 'cloud115' && (
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
+4 -1
View File
@@ -15,6 +15,7 @@ import {
RefreshCw,
Settings,
Trash2,
Tv,
Upload,
} from 'lucide-react'
@@ -55,6 +56,8 @@ export function providerIcon(provider: StrmProvider) {
return FolderPlus
case 'local':
return HardDrive
case 'emby_remote':
return Tv
}
}
@@ -71,7 +74,7 @@ export function StrmManagePage() {
const refresh = useCallback(async () => {
try {
const [accts, pths, recs] = await Promise.all([
strmAPI.listAccounts(),
strmAPI.listAccounts().then((rows) => rows.filter((a) => a.provider !== 'emby_remote')),
strmAPI.listPaths(),
strmAPI.listRecords(),
])
+1 -1
View File
@@ -55,7 +55,7 @@ function getFileIcon(filename: string): ReactNode {
return <File size={15} className="text-gray-400 shrink-0" />
}
function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
const [snapshot, setSnapshot] = useState<StrmQueueSnapshot | null>(null)
const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all')
const [search, setSearch] = useState('')
+148
View File
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Download, ListChecks, Sparkles, Upload } from 'lucide-react'
import clsx from 'clsx'
import { scraperAPI } from '../api/scraper'
import { strmAPI } from '../api/strm'
import { ScraperQueuePage } from './ScraperQueuePage'
import { StrmQueuePanel } from './StrmQueuePage'
type QueueType = 'all' | 'scrape' | 'download' | 'upload'
const QUEUE_TABS: { type: QueueType; label: string; icon: typeof ListChecks }[] = [
{ type: 'all', label: '全部', icon: ListChecks },
{ type: 'scrape', label: '刮削队列', icon: Sparkles },
{ type: 'download', label: '下载队列', icon: Download },
{ type: 'upload', label: '上传队列', icon: Upload },
]
function normalizeType(raw: string | null): QueueType {
if (raw === 'scrape' || raw === 'download' || raw === 'upload' || raw === 'all') {
return raw
}
return 'all'
}
/**
* 任务队列:整合刮削 / 下载 / 上传 三类队列的统一入口,按类型 Tab 区分。
* 单个类型视图直接复用原队列组件;「全部」纵向堆叠三个队列。
*/
export function TaskQueuePage() {
const [searchParams, setSearchParams] = useSearchParams()
const type = normalizeType(searchParams.get('type'))
const [scrapeCounts, setScrapeCounts] = useState<Record<string, number> | null>(null)
const [downloadCounts, setDownloadCounts] = useState<Record<string, number> | null>(null) // StrmQueueCounts
const [uploadCounts, setUploadCounts] = useState<Record<string, number> | null>(null)
const loadOverview = useCallback(async () => {
try {
const scrape = await scraperAPI.queue(undefined, 1, 1)
setScrapeCounts(scrape.counts as unknown as Record<string, number>)
} catch {
setScrapeCounts(null)
}
try {
const dl = await strmAPI.downloads(undefined, 1, 1)
setDownloadCounts(dl.counts as unknown as Record<string, number>)
} catch {
setDownloadCounts(null)
}
try {
const ul = await strmAPI.uploads(undefined, 1, 1)
setUploadCounts(ul.counts as unknown as Record<string, number>)
} catch {
setUploadCounts(null)
}
}, [])
useEffect(() => {
loadOverview()
}, [loadOverview])
const countBadge = (counts: Record<string, number> | null) => {
if (!counts) return null
const total = Object.values(counts).reduce((a, b) => a + (b || 0), 0)
return <span className="tab-count">{total}</span>
}
const switchType = (next: QueueType) => {
setSearchParams(next === 'all' ? {} : { type: next }, { replace: true })
}
return (
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">任务队列</h1>
<p className="mt-0.5 text-sm text-ink-50">刮削 / 下载 / 上传三类任务统一管理,按类型切换查看</p>
</div>
<button onClick={loadOverview} className="btn-outline" title="刷新概览计数">
<ListChecks size={14} />
刷新计数
</button>
</div>
{/* 类型 Tab */}
<div className="flex flex-wrap gap-2 border-b border-sand-200 pb-3">
{QUEUE_TABS.map((tab) => {
const active = type === tab.type
const Icon = tab.icon
const badge =
tab.type === 'scrape'
? countBadge(scrapeCounts)
: tab.type === 'download'
? countBadge(downloadCounts)
: tab.type === 'upload'
? countBadge(uploadCounts)
: null
return (
<button
key={tab.type}
onClick={() => switchType(tab.type)}
className={clsx(
'inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-semibold transition',
active ? 'bg-brand-500 text-white shadow-sm' : 'text-ink-100 hover:bg-brand-50 hover:text-brand-600',
)}
>
<Icon size={14} />
{tab.label}
{badge}
</button>
)
})}
</div>
{/* 内容 */}
{type === 'scrape' && <ScraperQueuePage />}
{type === 'download' && <StrmQueuePanel kind="download" />}
{type === 'upload' && <StrmQueuePanel kind="upload" />}
{type === 'all' && (
<div className="space-y-10">
<section>
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
<Sparkles size={18} className="text-brand-500" />
刮削队列
</h2>
<ScraperQueuePage />
</section>
<section>
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
<Download size={18} className="text-brand-500" />
下载队列
</h2>
<StrmQueuePanel kind="download" />
</section>
<section>
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
<Upload size={18} className="text-brand-500" />
上传队列
</h2>
<StrmQueuePanel kind="upload" />
</section>
</div>
)}
</div>
)
}
+4 -1
View File
@@ -18,7 +18,10 @@ export function useAdminLibraryPanel() {
function useAdminLibraryList() {
const [libs, setLibs] = useState<Library[]>([])
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
const refresh = () =>
libraryAPI
.list({ includeHidden: true })
.then((libs) => setLibs(libs.filter((l) => !l.is_remote_emby))) // 远程挂载库只读,不在后台管理列表内
useEffect(() => {
refresh().catch(() => undefined)
+36
View File
@@ -0,0 +1,36 @@
// Emby 远程挂载类型定义(与后端 internal/model/emby_mount.go 对应)。
export interface EmbyMount {
id: string
account_id: string
remote_view_id: string
remote_view_name: string
collection_type: string
name: string
proxy_play: boolean
enabled: boolean
created_at: string
updated_at: string
/** 所属账号名(接口侧拼装) */
account_name?: string
}
export interface EmbyMountInput {
account_id: string
views: EmbyViewInput[]
}
export interface EmbyViewInput {
remote_view_id: string
remote_view_name: string
collection_type: string
name?: string
proxy_play: boolean
}
export interface RemoteEmbyView {
remote_view_id: string
remote_view_name: string
collection_type: string
child_count: number
already_mounted: boolean
}
+3
View File
@@ -21,6 +21,9 @@ export interface Library {
roots?: LibraryRoot[]
created_at: string
updated_at: string
/** 远程 Emby 挂载库(只读,不支持扫描/刮削/编辑) */
is_remote_emby?: boolean
remote_source?: string
}
export interface ScanResult {
+4 -1
View File
@@ -1,11 +1,12 @@
// STRM 管理相关类型定义(与后端 internal/model/strm.go 对应)。
export type StrmProvider = 'cloud115' | 'clouddrive2' | 'openlist' | 'local'
export type StrmProvider = 'cloud115' | 'clouddrive2' | 'openlist' | 'local' | 'emby_remote'
export const STRM_PROVIDER_LABELS: Record<StrmProvider, string> = {
cloud115: '115 网盘',
clouddrive2: 'CloudDrive2',
openlist: 'OpenList',
local: '本地目录',
emby_remote: 'Emby 远程挂载',
}
export interface StrmAccount {
@@ -20,6 +21,8 @@ export interface StrmAccount {
last_test_ok: boolean
has_credential: boolean
provider_label: string
/** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */
proxy_play?: boolean
}
export interface StrmAccountInput {
+6
View File
@@ -0,0 +1,6 @@
// 远程 Emby 挂载条目 / 媒体库的识别与只读标记。
// 与后端 internal/service/emby_remote_ids.go 的 ID 伪装协议保持一致:
// embyremote~{accountID}~{remoteID}。
export function isRemoteEmbyID(id?: string | null): boolean {
return Boolean(id && id.startsWith('embyremote~'))
}