mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f534e0607a | |||
| db64a6c093 | |||
| 5c9e7fcaa6 | |||
| af67f4cd6e | |||
| 73de139d1f | |||
| e0cc481b96 | |||
| 7ac75ec69a | |||
| 0e73e43cbd | |||
| bd41ab3fb4 | |||
| 73b95a8e38 | |||
| 5a5555d28a | |||
| 4dc9cbe2e7 | |||
| 6af0e5fdcf | |||
| f0055b76fe | |||
| 0eb3f104f4 | |||
| c171b38155 | |||
| 89d7a6cbb2 | |||
| 85918d1196 | |||
| fa9307e2e1 | |||
| 5ab18c2725 | |||
| a4a2bde1a4 | |||
| 28113f5fdc | |||
| 42b8805e94 |
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 830 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -43,6 +43,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("logging.max_backups", 10)
|
||||
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 500)
|
||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mmtl")
|
||||
|
||||
@@ -44,6 +44,9 @@ func (c *Config) normalize() error {
|
||||
if c.Cache.CacheDir == "" {
|
||||
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
|
||||
}
|
||||
if c.Cache.ImagesMaxSizeMB < 0 {
|
||||
c.Cache.ImagesMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.RedisPrefix == "" {
|
||||
c.Cache.RedisPrefix = "mmtl"
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ type LoggingConfig struct {
|
||||
// CacheConfig 控制磁盘转码/刮削缓存。
|
||||
type CacheConfig struct {
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
||||
TTLHours int `mapstructure:"ttl_hours"`
|
||||
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
||||
|
||||
+73
-11
@@ -3,6 +3,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -27,6 +28,9 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if svc.Sessions != nil {
|
||||
svc.Sessions.ApplyToUsers(c.Request.Context(), users)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PopulateComputedFields()
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
}
|
||||
@@ -188,19 +192,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.IsActive {
|
||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
|
||||
} else {
|
||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
|
||||
if req.IsActive {
|
||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
|
||||
} else {
|
||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
|
||||
}
|
||||
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated.PopulateComputedFields()
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
}
|
||||
|
||||
type adminUpdateUserLibrariesReq struct {
|
||||
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
|
||||
}
|
||||
|
||||
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminUpdateUserLibrariesReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.Param("id")
|
||||
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var rawJSON string
|
||||
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
|
||||
var cleanIDs []string
|
||||
for _, id := range *req.AllowedLibraryIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed != "" {
|
||||
cleanIDs = append(cleanIDs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(cleanIDs) > 0 {
|
||||
data, err := json.Marshal(cleanIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rawJSON = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
updates := map[string]any{"allowed_library_ids": rawJSON}
|
||||
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil || updated == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
|
||||
return
|
||||
}
|
||||
updated.PopulateComputedFields()
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
||||
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
||||
|
||||
@@ -64,6 +64,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
|
||||
svc.Transcoder.StopAll()
|
||||
}
|
||||
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
|
||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -43,7 +44,92 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
||||
t.Fatal("recent realtime user should not be deleted")
|
||||
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
||||
t.Fatal("recent realtime user should not be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserLibraries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
lib1 := model.Library{Base: model.Base{ID: "lib-1"}, Name: "电影", Type: "movie", Path: "/movie"}
|
||||
lib2 := model.Library{Base: model.Base{ID: "lib-2"}, Name: "剧集", Type: "tv", Path: "/tv"}
|
||||
lib3 := model.Library{Base: model.Base{ID: "lib-3"}, Name: "动漫", Type: "anime", Path: "/anime"}
|
||||
if err := repos.DB.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Create(&[]model.Library{lib1, lib2, lib3}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{Repo: repos}
|
||||
router := gin.New()
|
||||
router.PATCH("/admin/users/:id/libraries", updateUserLibrariesHandler(svc))
|
||||
|
||||
// 1. 设置限制为 lib-1 和 lib-2
|
||||
body := `{"allowed_library_ids":["lib-1","lib-2"]}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
found, err := repos.User.FindByID(t.Context(), "u1")
|
||||
if err != nil || found == nil {
|
||||
t.Fatal("user not found")
|
||||
}
|
||||
allowed := found.DecodeAllowedLibraryIDs()
|
||||
if len(allowed) != 2 || allowed[0] != "lib-1" || allowed[1] != "lib-2" {
|
||||
t.Fatalf("expected [lib-1, lib-2], got %v", allowed)
|
||||
}
|
||||
|
||||
// 验证可见性
|
||||
vis := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
|
||||
if len(vis.AllowedLibraryIDs) != 2 {
|
||||
t.Fatalf("expected 2 allowed libraries, got %v", vis.AllowedLibraryIDs)
|
||||
}
|
||||
if !service.LibraryVisibleForUser(t.Context(), repos, lib1, vis) {
|
||||
t.Fatal("lib1 should be visible")
|
||||
}
|
||||
if !service.LibraryVisibleForUser(t.Context(), repos, lib2, vis) {
|
||||
t.Fatal("lib2 should be visible")
|
||||
}
|
||||
if service.LibraryVisibleForUser(t.Context(), repos, lib3, vis) {
|
||||
t.Fatal("lib3 should not be visible")
|
||||
}
|
||||
|
||||
// 2. 清空限制,恢复全部可见
|
||||
bodyEmpty := `{"allowed_library_ids":[]}`
|
||||
reqEmpty := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(bodyEmpty))
|
||||
reqEmpty.Header.Set("Content-Type", "application/json")
|
||||
wEmpty := httptest.NewRecorder()
|
||||
router.ServeHTTP(wEmpty, reqEmpty)
|
||||
|
||||
if wEmpty.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", wEmpty.Code, wEmpty.Body.String())
|
||||
}
|
||||
|
||||
foundReset, _ := repos.User.FindByID(t.Context(), "u1")
|
||||
if len(foundReset.DecodeAllowedLibraryIDs()) != 0 {
|
||||
t.Fatalf("expected nil or empty, got %v", foundReset.DecodeAllowedLibraryIDs())
|
||||
}
|
||||
|
||||
visReset := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
|
||||
if len(visReset.AllowedLibraryIDs) != 0 {
|
||||
t.Fatalf("expected no library restrictions, got %v", visReset.AllowedLibraryIDs)
|
||||
}
|
||||
if !service.LibraryVisibleForUser(t.Context(), repos, lib3, visReset) {
|
||||
t.Fatal("lib3 should now be visible")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ func meHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
u.PopulateComputedFields()
|
||||
c.JSON(http.StatusOK, u)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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})
|
||||
}
|
||||
}
|
||||
|
||||
type reorderEmbyMountsReq struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
// reorderEmbyMountsHandler 批量重排挂载媒体库顺序。
|
||||
func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req reorderEmbyMountsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.EmbyRemote == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
|
||||
return
|
||||
}
|
||||
if err := svc.EmbyRemote.ReorderMounts(c.Request.Context(), req.IDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func TestReorderEmbyMountsHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := t.Context()
|
||||
|
||||
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
|
||||
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
|
||||
_ = repos.EmbyMount.Create(ctx, m1)
|
||||
_ = repos.EmbyMount.Create(ctx, m2)
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"ids": []string{m2.ID, m1.ID},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
list, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID {
|
||||
t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+221
-49
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -16,18 +17,41 @@ import (
|
||||
)
|
||||
|
||||
type createLibraryReq struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
Roots []service.LibraryRootInput `json:"roots"`
|
||||
Type string `json:"type"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
CreatePerSubfolder bool `json:"create_per_subfolder"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
Roots []service.LibraryRootInput `json:"roots"`
|
||||
Type string `json:"type"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
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 +59,102 @@ 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 {
|
||||
remotePayloads := make([]webLibraryPayload, len(views))
|
||||
for i, v := range views {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
}
|
||||
if withPreview && len(views) > 0 {
|
||||
const maxRemotePreviewWorkers = 6
|
||||
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
||||
var wg sync.WaitGroup
|
||||
for i, v := range views {
|
||||
i, v := i, v
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = cards
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
out = append(out, remotePayloads...)
|
||||
}
|
||||
}
|
||||
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 +166,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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,38 +192,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
||||
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||
}
|
||||
var l *model.Library
|
||||
if req.CreatePerSubfolder {
|
||||
parent := ""
|
||||
if len(roots) > 0 {
|
||||
parent = roots[0].Path
|
||||
} else if strings.TrimSpace(req.Path) != "" {
|
||||
parent = req.Path
|
||||
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||
}
|
||||
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get("ctx_user_id")
|
||||
for i := range created {
|
||||
lib := &created[i]
|
||||
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
|
||||
if svc.Watcher != nil {
|
||||
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
|
||||
var l *model.Library
|
||||
if req.CreatePerSubfolder {
|
||||
parent := ""
|
||||
if len(roots) > 0 {
|
||||
parent = roots[0].Path
|
||||
} else if strings.TrimSpace(req.Path) != "" {
|
||||
parent = req.Path
|
||||
}
|
||||
for _, root := range lib.Roots {
|
||||
if root.Enabled {
|
||||
queueLibraryRootScan(svc, lib.ID, root.ID)
|
||||
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get("ctx_user_id")
|
||||
for i := range created {
|
||||
lib := &created[i]
|
||||
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
|
||||
if svc.Watcher != nil {
|
||||
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
|
||||
}
|
||||
for _, root := range lib.Roots {
|
||||
if root.Enabled {
|
||||
queueLibraryRootScan(svc, lib.ID, root.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"libraries": created})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"libraries": created})
|
||||
return
|
||||
}
|
||||
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -232,8 +319,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 +387,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 +507,37 @@ 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) {
|
||||
if !enforceScopedPlaybackToken(c, id) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
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
|
||||
|
||||
@@ -19,21 +19,40 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func findMediaForPlaybackEndpoint(c *gin.Context, svc *service.Container, id string) (*model.Media, error) {
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
return svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
||||
}
|
||||
return svc.Repo.Media.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
// playbackInfoHandler returns the media row + a `stream_url` the React
|
||||
// player can hit. Mirrors the Python project's surface.
|
||||
func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
id := c.Param("id")
|
||||
m, err := findMediaForPlaybackEndpoint(c, svc, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
profileQuery := externalProfileQuery(c)
|
||||
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
|
||||
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
|
||||
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"media": m,
|
||||
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
||||
"hls_url": "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery,
|
||||
"hls_url": hlsURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,7 +87,8 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// produce the per-player launch URL.
|
||||
func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
id := c.Param("id")
|
||||
m, err := findMediaForPlaybackEndpoint(c, svc, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
@@ -93,7 +113,8 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// token query string the external player needs.
|
||||
func externalURLHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
id := c.Param("id")
|
||||
m, err := findMediaForPlaybackEndpoint(c, svc, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
|
||||
@@ -401,11 +401,81 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||
api.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
api.GET("/stream/:id", streamHandler(svc))
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
router := gin.New()
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||
api.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
api.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
api.GET("/stream/:id", streamHandler(svc))
|
||||
api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
||||
api.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
}
|
||||
|
||||
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/info", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
StreamURL string `json:"stream_url"`
|
||||
HlsURL string `json:"hls_url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if payload.StreamURL == "" {
|
||||
t.Fatalf("expected non-empty stream_url")
|
||||
}
|
||||
if payload.HlsURL != "" {
|
||||
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.EmbyRemote = &service.EmbyRemoteService{}
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/hls/embyremote~acct1~item1/index.m3u8", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d (409 StatusConflict)", w.Code, http.StatusConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.EmbyRemote = &service.EmbyRemoteService{}
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/media/embyremote~acct1~item1/subtitles", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 OK", w.Code)
|
||||
}
|
||||
var payload struct {
|
||||
Tracks []any `json:"tracks"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if payload.Tracks == nil || len(payload.Tracks) != 0 {
|
||||
t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,15 @@ 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/reorder", reorderEmbyMountsHandler(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,8 +94,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
|
||||
admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc))
|
||||
admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/clear-done", clearDoneUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/clear-finished", clearFinishedUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/retry-failed", retryAllFailedUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
@@ -96,6 +108,7 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
|
||||
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
|
||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
|
||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||
admin.GET("/settings", listSettingsHandler(svc))
|
||||
admin.PUT("/settings", updateSettingHandler(svc))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,13 @@ import (
|
||||
|
||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
id := c.Param("id")
|
||||
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -43,7 +49,12 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
id := c.Param("id")
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -496,6 +504,28 @@ func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func clearFinishedUploadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Strm.ClearFinishedUploadTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context())
|
||||
@@ -507,6 +537,17 @@ func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func retryAllFailedUploadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Strm.RetryAllFailedUploadTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"retried": n})
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Strm.CancelPendingDownloadTasks(c.Request.Context())
|
||||
|
||||
@@ -57,8 +57,11 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
|
||||
"GET /api/admin/strm/uploads",
|
||||
"POST /api/admin/strm/uploads/:id/cancel",
|
||||
"POST /api/admin/strm/uploads/:id/retry",
|
||||
"POST /api/admin/strm/uploads/cancel-pending",
|
||||
"POST /api/admin/strm/uploads/clear-done",
|
||||
"POST /api/admin/strm/uploads/clear-finished",
|
||||
"POST /api/admin/strm/uploads/clear-canceled",
|
||||
"POST /api/admin/strm/uploads/retry-failed",
|
||||
"POST /api/admin/strm/uploads/cancel-pending",
|
||||
"GET /api/strm/play/:provider/:file",
|
||||
} {
|
||||
if !routes[want] {
|
||||
|
||||
@@ -11,7 +11,12 @@ import (
|
||||
|
||||
func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tracks, err := svc.Subtitle.Discover(c.Request.Context(), c.Param("id"))
|
||||
id := c.Param("id")
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
|
||||
return
|
||||
}
|
||||
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -32,7 +32,14 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M
|
||||
return visibility
|
||||
}
|
||||
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult
|
||||
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
|
||||
profileAllowed := profileAllowedLibraryIDs(*profile)
|
||||
if len(profileAllowed) > 0 {
|
||||
if len(visibility.AllowedLibraryIDs) > 0 {
|
||||
visibility.AllowedLibraryIDs = service.IntersectStrings(visibility.AllowedLibraryIDs, profileAllowed)
|
||||
} else {
|
||||
visibility.AllowedLibraryIDs = profileAllowed
|
||||
}
|
||||
}
|
||||
if !visibility.IncludeNSFW {
|
||||
visibility.HiddenLibraryIDs = service.AdultLibraryIDs(c.Request.Context(), svc.Repo)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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"` // 覆盖显示名(可选,默认「账号 · 库名」)
|
||||
SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
|
||||
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
|
||||
}
|
||||
@@ -58,5 +58,6 @@ func AllModels() []interface{} {
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&EmbyMount{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
StrmProviderCloudDrive = "clouddrive2" // CloudDrive2(WebDAV 桥接)
|
||||
StrmProviderOpenList = "openlist" // OpenList / AList 兼容桥接
|
||||
StrmProviderLocal = "local" // 本地目录(无账号)
|
||||
StrmProviderEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关聚合挂载,不走 STRM 同步)
|
||||
)
|
||||
|
||||
// StrmAccount 是一个网盘账号(STRM 同步数据源凭据)。
|
||||
|
||||
+36
-1
@@ -1,6 +1,10 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色;
|
||||
// 其他所有用户默认为 "user"。
|
||||
@@ -17,6 +21,10 @@ type User struct {
|
||||
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
// AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。
|
||||
// 为空时代表不限制(全库可访问)。
|
||||
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
@@ -31,3 +39,30 @@ type User struct {
|
||||
RealtimeOnline bool `gorm:"-" json:"realtime_online,omitempty"`
|
||||
RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"`
|
||||
}
|
||||
|
||||
// DecodeAllowedLibraryIDs 解析 AllowedLibraryIDs 字段。
|
||||
func (u *User) DecodeAllowedLibraryIDs() []string {
|
||||
if u == nil || strings.TrimSpace(u.AllowedLibraryIDs) == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(u.AllowedLibraryIDs), &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, id := range ids {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。
|
||||
func (u *User) PopulateComputedFields() {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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).Transaction(func(tx *gorm.DB) error {
|
||||
if m != nil && m.SortOrder == 0 {
|
||||
var maxSort int
|
||||
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
|
||||
m.SortOrder = maxSort + 1
|
||||
}
|
||||
return tx.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).Transaction(func(tx *gorm.DB) error {
|
||||
var maxSort int
|
||||
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
|
||||
for _, m := range mounts {
|
||||
if m != nil && m.SortOrder == 0 {
|
||||
maxSort++
|
||||
m.SortOrder = maxSort
|
||||
}
|
||||
}
|
||||
return tx.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("sort_order asc, created_at asc").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("sort_order asc, created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&model.EmbyMount{}).Where("id = ?", id).
|
||||
Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
func TestEmbyMountSortOrderAndReorder(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx := t.Context()
|
||||
|
||||
// 1. Create mounts and verify auto-assigned sort_order
|
||||
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
|
||||
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
|
||||
m3 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-3", Name: "Mount 3"}
|
||||
|
||||
if err := repos.EmbyMount.Create(ctx, m1); err != nil {
|
||||
t.Fatalf("create m1: %v", err)
|
||||
}
|
||||
if err := repos.EmbyMount.Create(ctx, m2); err != nil {
|
||||
t.Fatalf("create m2: %v", err)
|
||||
}
|
||||
if err := repos.EmbyMount.Create(ctx, m3); err != nil {
|
||||
t.Fatalf("create m3: %v", err)
|
||||
}
|
||||
|
||||
if m1.SortOrder >= m2.SortOrder || m2.SortOrder >= m3.SortOrder {
|
||||
t.Fatalf("expected ascending sort order on create: m1=%d, m2=%d, m3=%d",
|
||||
m1.SortOrder, m2.SortOrder, m3.SortOrder)
|
||||
}
|
||||
|
||||
// 2. Query list and verify initial order
|
||||
list, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list mounts: %v", err)
|
||||
}
|
||||
if len(list) != 3 || list[0].ID != m1.ID || list[1].ID != m2.ID || list[2].ID != m3.ID {
|
||||
t.Fatalf("unexpected list order: %+v", list)
|
||||
}
|
||||
|
||||
// 3. Reorder: m3, m1, m2
|
||||
if err := repos.EmbyMount.SetSortOrder(ctx, []string{m3.ID, m1.ID, m2.ID}); err != nil {
|
||||
t.Fatalf("SetSortOrder failed: %v", err)
|
||||
}
|
||||
|
||||
// 4. Query list again and verify updated order
|
||||
reordered, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list mounts after reorder: %v", err)
|
||||
}
|
||||
if len(reordered) != 3 {
|
||||
t.Fatalf("expected 3 mounts, got %d", len(reordered))
|
||||
}
|
||||
if reordered[0].ID != m3.ID || reordered[1].ID != m1.ID || reordered[2].ID != m2.ID {
|
||||
t.Fatalf("expected order [m3, m1, m2], got: %s, %s, %s",
|
||||
reordered[0].ID, reordered[1].ID, reordered[2].ID)
|
||||
}
|
||||
if reordered[0].SortOrder != 0 || reordered[1].SortOrder != 1 || reordered[2].SortOrder != 2 {
|
||||
t.Fatalf("unexpected sort orders: %d, %d, %d",
|
||||
reordered[0].SortOrder, reordered[1].SortOrder, reordered[2].SortOrder)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,6 +675,29 @@ func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ClearDone 清空全部已完成上传任务。
|
||||
func (r *StrmUploadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskDone).Delete(&model.StrmUploadTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ClearFinished 清空全部已完成与失败上传任务(包括已完成、失败及取消)。
|
||||
func (r *StrmUploadTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}).
|
||||
Delete(&model.StrmUploadTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ClearCanceled 清空全部已取消上传任务。
|
||||
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
@@ -686,6 +709,27 @@ func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, er
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
|
||||
func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Where("status = ?", model.StrmTaskFailed).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"next_try_at": nil,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CancelPending 批量取消所有排队中和进行中的任务。
|
||||
func (r *StrmUploadTaskRepository) CancelPending(ctx context.Context) (int64, error) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -42,3 +45,97 @@ func walkAndPrune(root string, cutoff time.Time) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneImageCacheResult holds stats from an image cache prune operation.
|
||||
type PruneImageCacheResult struct {
|
||||
TotalFilesBefore int
|
||||
TotalBytesBefore int64
|
||||
DeletedFiles int
|
||||
FreedBytes int64
|
||||
RemainingBytes int64
|
||||
}
|
||||
|
||||
type imageCacheFileEntry struct {
|
||||
path string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// PruneImageCache scans imagesDir for cached image files. If the total disk usage
|
||||
// exceeds maxSizeBytes, it removes files starting from the oldest (by ModTime)
|
||||
// until disk usage falls to or below targetSizeBytes (80% of maxSizeBytes).
|
||||
//
|
||||
// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes.
|
||||
// Empty subdirectories left behind are best-effort removed.
|
||||
func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) {
|
||||
var result PruneImageCacheResult
|
||||
if imagesDir == "" || maxSizeBytes <= 0 {
|
||||
return result, nil
|
||||
}
|
||||
if _, err := os.Stat(imagesDir); err != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var (
|
||||
dirs []string
|
||||
entries []imageCacheFileEntry
|
||||
)
|
||||
|
||||
_ = filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if path != imagesDir {
|
||||
dirs = append(dirs, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Skip temporary files created during image download.
|
||||
name := info.Name()
|
||||
if strings.HasSuffix(name, ".tmp") || strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") {
|
||||
return nil
|
||||
}
|
||||
size := info.Size()
|
||||
result.TotalFilesBefore++
|
||||
result.TotalBytesBefore += size
|
||||
entries = append(entries, imageCacheFileEntry{
|
||||
path: path,
|
||||
size: size,
|
||||
modTime: info.ModTime(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
result.RemainingBytes = result.TotalBytesBefore
|
||||
if result.TotalBytesBefore <= maxSizeBytes {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// High/Low watermark: prune down to 80% of max size to leave headroom
|
||||
// and prevent disk thrashing on consecutive writes.
|
||||
targetSizeBytes := maxSizeBytes * 80 / 100
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].modTime.Before(entries[j].modTime)
|
||||
})
|
||||
|
||||
for _, entry := range entries {
|
||||
if result.RemainingBytes <= targetSizeBytes {
|
||||
break
|
||||
}
|
||||
if err := os.Remove(entry.path); err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
result.DeletedFiles++
|
||||
result.FreedBytes += entry.size
|
||||
result.RemainingBytes -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up emptied subdirectories from deepest to shallowest.
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
_ = os.Remove(dirs[i])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestPruneImageCache_UnderLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
file1 := filepath.Join(dir, "img1")
|
||||
file2 := filepath.Join(dir, "img2")
|
||||
if err := os.WriteFile(file1, make([]byte, 100), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file2, make([]byte, 200), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Max limit is 500 bytes, total is 300 bytes -> no prune
|
||||
res, err := PruneImageCache(dir, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 0 {
|
||||
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
|
||||
}
|
||||
if res.TotalFilesBefore != 2 || res.TotalBytesBefore != 300 || res.RemainingBytes != 300 {
|
||||
t.Fatalf("unexpected stats: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneImageCache_OverLimitLRU(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
now := time.Now()
|
||||
// Create 4 files of 100 bytes each, with distinct mtime
|
||||
fOldest := filepath.Join(dir, "oldest")
|
||||
fMidOld := filepath.Join(dir, "mid_old")
|
||||
fMidNew := filepath.Join(dir, "mid_new")
|
||||
fNewest := filepath.Join(dir, "newest")
|
||||
|
||||
for _, f := range []string{fOldest, fMidOld, fMidNew, fNewest} {
|
||||
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.Chtimes(fOldest, now.Add(-4*time.Hour), now.Add(-4*time.Hour))
|
||||
_ = os.Chtimes(fMidOld, now.Add(-3*time.Hour), now.Add(-3*time.Hour))
|
||||
_ = os.Chtimes(fMidNew, now.Add(-2*time.Hour), now.Add(-2*time.Hour))
|
||||
_ = os.Chtimes(fNewest, now.Add(-1*time.Hour), now.Add(-1*time.Hour))
|
||||
|
||||
// Total = 400 bytes. Max limit = 300 bytes.
|
||||
// Target = 300 * 80 / 100 = 240 bytes.
|
||||
// Deleting oldest (100) brings total to 300 (> 240).
|
||||
// Deleting mid_old (100) brings total to 200 (<= 240).
|
||||
// Total deleted = 2 files (200 bytes), remaining = 200 bytes.
|
||||
res, err := PruneImageCache(dir, 300)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 2 {
|
||||
t.Fatalf("expected 2 deleted files, got %d", res.DeletedFiles)
|
||||
}
|
||||
if res.FreedBytes != 200 {
|
||||
t.Fatalf("expected 200 freed bytes, got %d", res.FreedBytes)
|
||||
}
|
||||
if res.RemainingBytes != 200 {
|
||||
t.Fatalf("expected 200 remaining bytes, got %d", res.RemainingBytes)
|
||||
}
|
||||
|
||||
// Verify oldest and mid_old were deleted, mid_new and newest still exist
|
||||
if _, err := os.Stat(fOldest); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected oldest file to be deleted, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(fMidOld); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected mid_old file to be deleted, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(fMidNew); err != nil {
|
||||
t.Fatalf("expected mid_new file to exist, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(fNewest); err != nil {
|
||||
t.Fatalf("expected newest file to exist, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneImageCache_SkipsTmpFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
fTmp := filepath.Join(dir, "img-123.tmp")
|
||||
fImg := filepath.Join(dir, "cached_img")
|
||||
|
||||
if err := os.WriteFile(fTmp, make([]byte, 500), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fImg, make([]byte, 100), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Limit is 200 bytes. fTmp (500) is ignored, only fImg (100) is counted <= 200.
|
||||
res, err := PruneImageCache(dir, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 0 {
|
||||
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
|
||||
}
|
||||
if _, err := os.Stat(fTmp); err != nil {
|
||||
t.Fatalf("expected tmp file to remain untouched, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "img")
|
||||
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := PruneImageCache(dir, 0)
|
||||
if err != nil || res.DeletedFiles != 0 {
|
||||
t.Fatalf("expected no-op for 0 limit, got %+v, err=%v", res, err)
|
||||
}
|
||||
|
||||
res, err = PruneImageCache(dir, -10)
|
||||
if err != nil || res.DeletedFiles != 0 {
|
||||
t.Fatalf("expected no-op for negative limit, got %+v, err=%v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerJobCleanImageCache(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
imagesDir := filepath.Join(cacheRoot, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f := filepath.Join(imagesDir, "old_poster")
|
||||
if err := os.WriteFile(f, make([]byte, 2*1024*1024), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot)
|
||||
// Set limit to 1MB; our file is 2MB -> should be pruned
|
||||
scheduler.SetImagesMaxSizeMBProvider(func() int {
|
||||
return 1
|
||||
})
|
||||
|
||||
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
|
||||
t.Fatalf("jobCleanImageCache failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(f); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected file to be pruned, got err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -343,4 +343,106 @@ func TestDanmakuSameBase(t *testing.T) {
|
||||
require.Equal(t, int64(99999), resManual.EpisodeID)
|
||||
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
|
||||
require.Contains(t, resManual.Raw, "手动搜索弹幕")
|
||||
}
|
||||
}
|
||||
|
||||
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
|
||||
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
|
||||
content := bytes.Repeat([]byte("emby-remote-video-bytes-9876543210"), 300)
|
||||
sum := md5.Sum(content)
|
||||
wantHash := hex.EncodeToString(sum[:])
|
||||
|
||||
var gotRange string
|
||||
var rangeHits int
|
||||
rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rangeHits++
|
||||
gotRange = r.Header.Get("Range")
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
t.Cleanup(rangeSrv.Close)
|
||||
|
||||
var seen string
|
||||
official := danmakuOfficialServer(t,
|
||||
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":2001,"animeTitle":"芙莉莲","episodeTitle":"第1话"}]}`,
|
||||
`<?xml version="1.0"?><i><d p="1.2,1,16777215,user1">Emby远程弹幕命中</d></i>`,
|
||||
&seen)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-456")
|
||||
svc := newDanmakuTestService(t)
|
||||
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
|
||||
require.Equal(t, remoteMediaID, encodedID)
|
||||
return &model.Media{
|
||||
Base: model.Base{ID: remoteMediaID},
|
||||
Title: "葬送的芙莉莲",
|
||||
EpisodeTitle: "第1话",
|
||||
EpisodeNum: 1,
|
||||
Path: "/mnt/emby/anime/Frieren/S01E01.mkv",
|
||||
SizeBytes: int64(len(content)),
|
||||
DurationSec: 1400,
|
||||
}, rangeSrv.URL, nil
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Equal(t, "hash", res.MatchMode)
|
||||
require.Equal(t, int64(25484), res.EpisodeID)
|
||||
require.Equal(t, "芙莉莲", res.AnimeTitle)
|
||||
require.Contains(t, res.Raw, "Emby远程弹幕命中")
|
||||
require.Contains(t, gotRange, "bytes=0-")
|
||||
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
|
||||
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("S01E01")+`"`)
|
||||
require.Equal(t, 1, rangeHits)
|
||||
|
||||
// 第二次拉取验证 hashCache 命中,不重复请求 rangeSrv
|
||||
res2, err := svc.Fetch(ctx, remoteMediaID, "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hash", res2.MatchMode)
|
||||
require.Equal(t, 1, rangeHits)
|
||||
}
|
||||
|
||||
// Emby 远程直链拉取失败时(如网络异常),能平滑降级走番剧原名/标题关键词搜索。
|
||||
func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 文件名搜索 ep01 时无结果,模拟文件名未匹配
|
||||
if r.URL.Query().Get("anime") == "ep01" {
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
|
||||
return
|
||||
}
|
||||
// 降级到番剧名搜索命中
|
||||
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":3001,"animeTitle":"降级搜索番剧","episodes":[{"episodeId":7799,"episodeTitle":"第1话"}]}]}`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/7799", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.8,1,16777215,user1">降级搜索弹幕</d></i>`)
|
||||
})
|
||||
official := httptest.NewServer(mux)
|
||||
t.Cleanup(official.Close)
|
||||
overrideDanmakuOfficialBase(t, official.URL)
|
||||
|
||||
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-789")
|
||||
svc := newDanmakuTestService(t)
|
||||
// 返回一个不存在的流服务地址模拟 Range 拉取失败
|
||||
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
|
||||
return &model.Media{
|
||||
Base: model.Base{ID: remoteMediaID},
|
||||
Title: "降级搜索番剧",
|
||||
EpisodeNum: 1,
|
||||
Path: "/mnt/emby/anime/fallback/ep01.mkv",
|
||||
DurationSec: 1200,
|
||||
}, "http://127.0.0.1:1/invalid-stream", nil
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Enabled)
|
||||
require.Equal(t, "search", res.MatchMode)
|
||||
require.Equal(t, int64(7799), res.EpisodeID)
|
||||
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
|
||||
require.Contains(t, res.Raw, "降级搜索弹幕")
|
||||
}
|
||||
@@ -94,6 +94,10 @@ type DanmakuEpisode struct {
|
||||
EpisodeTitle string `json:"episodeTitle"`
|
||||
}
|
||||
|
||||
// DanmakuRemoteMediaResolver resolves an Emby remote pseudo-ID (e.g. embyremote~mount~id)
|
||||
// into a memory model.Media and a direct stream URL.
|
||||
type DanmakuRemoteMediaResolver func(ctx context.Context, encodedID string) (*model.Media, string, error)
|
||||
|
||||
// DanmakuService fetches danmaku for a media item through the dandanplay
|
||||
// protocol: match by 16MB-prefix hash, then search for an episode id by the
|
||||
// video's name, then fetch the comment library XML. The React player parses
|
||||
@@ -108,6 +112,10 @@ type DanmakuService struct {
|
||||
// StrmService.ResolvePlay; nil means strm sources are skipped.
|
||||
strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)
|
||||
|
||||
// remoteResolve resolves an Emby remote pseudo-ID into *model.Media and
|
||||
// direct stream URL for range hashing.
|
||||
remoteResolve DanmakuRemoteMediaResolver
|
||||
|
||||
hashCacheMu sync.Mutex
|
||||
hashCache map[string]string // stamp → 16MB-prefix MD5
|
||||
}
|
||||
@@ -136,6 +144,14 @@ func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provi
|
||||
}
|
||||
}
|
||||
|
||||
// SetRemoteMediaResolver wires the resolver used to fetch metadata and direct
|
||||
// stream URLs for Emby remote mounted media.
|
||||
func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolver) {
|
||||
if s != nil {
|
||||
s.remoteResolve = resolve
|
||||
}
|
||||
}
|
||||
|
||||
// Config reads danmaku settings from the runtime settings table.
|
||||
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
|
||||
cfg := DanmakuRenderConfig{
|
||||
@@ -220,13 +236,17 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
|
||||
target := ""
|
||||
|
||||
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
|
||||
if target == "" && !manualKeyword && media != nil && media.Path != "" {
|
||||
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
|
||||
if hash, ok := s.mediaHash(ctx, media); ok {
|
||||
fileSize := media.SizeBytes
|
||||
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
|
||||
}
|
||||
matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
|
||||
matchName := danmakuMatchFileName(media.Path)
|
||||
if matchName == "" {
|
||||
matchName = term.name
|
||||
}
|
||||
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
} else if len(matches) > 0 {
|
||||
@@ -311,6 +331,29 @@ type danmakuSearchTerms struct {
|
||||
// (movies / unknown) is left empty so the search does not filter by episode.
|
||||
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) {
|
||||
var term danmakuSearchTerms
|
||||
if IsEmbyRemoteID(mediaID) {
|
||||
if s == nil || s.remoteResolve == nil {
|
||||
return term, nil, errors.New("remote emby resolver unavailable")
|
||||
}
|
||||
m, _, err := s.remoteResolve(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
if err != nil {
|
||||
return term, nil, err
|
||||
}
|
||||
return term, nil, errors.New("media not found")
|
||||
}
|
||||
if name := strings.TrimSpace(m.OriginalName); name != "" {
|
||||
term.name = name
|
||||
} else if name := strings.TrimSpace(m.Title); name != "" {
|
||||
term.name = name
|
||||
} else {
|
||||
term.name = danmakuMatchFileName(m.Path)
|
||||
}
|
||||
if m.EpisodeNum > 0 {
|
||||
term.episode = strconv.Itoa(m.EpisodeNum)
|
||||
}
|
||||
return term, m, nil
|
||||
}
|
||||
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||
return term, nil, errors.New("media repository unavailable")
|
||||
}
|
||||
@@ -484,7 +527,14 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
|
||||
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
|
||||
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
|
||||
func danmakuMatchFileName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.ReplaceAll(path, "\\", "/")
|
||||
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
|
||||
clean = clean[idx+1:]
|
||||
}
|
||||
base := filepath.Base(clean)
|
||||
if ext := filepath.Ext(base); ext != "" {
|
||||
base = strings.TrimSuffix(base, ext)
|
||||
}
|
||||
@@ -493,14 +543,20 @@ func danmakuMatchFileName(path string) string {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
}
|
||||
return base
|
||||
return strings.TrimSpace(base)
|
||||
}
|
||||
|
||||
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
|
||||
// video). Local videos are hashed straight from disk; .strm indirections are
|
||||
// resolved (local path / direct link) and only the 16MB prefix is downloaded.
|
||||
// video). Local videos are hashed straight from disk; .strm indirections and
|
||||
// remote Emby streams are range-fetched and only the 16MB prefix is downloaded.
|
||||
func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) {
|
||||
if media == nil || media.Path == "" {
|
||||
if media == nil {
|
||||
return "", false
|
||||
}
|
||||
if IsEmbyRemoteID(media.ID) {
|
||||
return s.hashEmbyRemote(ctx, media)
|
||||
}
|
||||
if media.Path == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
|
||||
@@ -517,6 +573,43 @@ func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (str
|
||||
return s.hashLocalFile(media.Path)
|
||||
}
|
||||
|
||||
// hashEmbyRemote computes the 16MB-prefix MD5 of a remote Emby stream via HTTP Range.
|
||||
func (s *DanmakuService) hashEmbyRemote(ctx context.Context, media *model.Media) (string, bool) {
|
||||
if media == nil || media.ID == "" {
|
||||
return "", false
|
||||
}
|
||||
if h, ok := s.hashCacheGet("e|" + media.ID); ok {
|
||||
return h, true
|
||||
}
|
||||
if s.remoteResolve == nil {
|
||||
return "", false
|
||||
}
|
||||
_, streamURL, err := s.remoteResolve(ctx, media.ID)
|
||||
if err != nil || strings.TrimSpace(streamURL) == "" {
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku emby stream url resolve failed, hash layer skipped",
|
||||
zap.String("media_id", media.ID), zap.Error(err))
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
body, err := s.openRangeBody(ctx, streamURL, nil)
|
||||
if err != nil || body == nil {
|
||||
if err != nil {
|
||||
s.log.Warn("danmaku emby range fetch failed, hash layer skipped",
|
||||
zap.String("media_id", media.ID), zap.Error(err))
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
defer body.Close()
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil {
|
||||
return "", false
|
||||
}
|
||||
hash := hex.EncodeToString(h.Sum(nil))
|
||||
s.hashCachePut("e|"+media.ID, hash)
|
||||
return hash, true
|
||||
}
|
||||
|
||||
// hashLocalFile computes the MD5 of the first 16MB of a local video, cached
|
||||
// by path+size+mtime so repeated danmaku loads skip the disk read.
|
||||
func (s *DanmakuService) hashLocalFile(path string) (string, bool) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
// 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"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"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
|
||||
cache *RuntimeCacheService
|
||||
}
|
||||
|
||||
// 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},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyRemoteService {
|
||||
if r != nil {
|
||||
r.cache = cache
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) remoteMediaCacheTTL() time.Duration {
|
||||
seconds := 15
|
||||
if r != nil && r.cfg != nil && r.cfg.Cache.MediaTTLSeconds > 0 {
|
||||
seconds = r.cfg.Cache.MediaTTLSeconds
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) remoteCacheKey(parts ...string) string {
|
||||
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
|
||||
return "media:embyremote:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) invalidateRemoteMediaCache(ctx context.Context) {
|
||||
if r != nil && r.cache != nil {
|
||||
r.cache.DeletePrefix(ctx, "media:embyremote:")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
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
|
||||
}
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
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
|
||||
}
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// DeleteMount 删除挂载。
|
||||
func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
|
||||
err := r.repo.EmbyMount.Delete(ctx, id)
|
||||
if err == nil {
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ReorderMounts 批量重排挂载媒体库顺序。
|
||||
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := r.repo.EmbyMount.SetSortOrder(ctx, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
cacheKey := r.remoteCacheKey("views", acct.ID, r.remoteUserID(cfg))
|
||||
var cached []map[string]any
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
if body.Items == nil {
|
||||
body.Items = []map[string]any{}
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, body.Items, r.remoteMediaCacheTTL())
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,99 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
|
||||
r := &EmbyRemoteService{}
|
||||
item := map[string]any{
|
||||
"Id": "item-100",
|
||||
"Name": "Test Movie",
|
||||
"Container": "mkv",
|
||||
"MediaStreams": []any{
|
||||
map[string]any{
|
||||
"Type": "Video",
|
||||
"Codec": "h264",
|
||||
"Width": 1920,
|
||||
"Height": 1080,
|
||||
},
|
||||
map[string]any{
|
||||
"Type": "Audio",
|
||||
"Codec": "aac",
|
||||
},
|
||||
},
|
||||
"MediaSources": []any{
|
||||
map[string]any{
|
||||
"Container": "mkv",
|
||||
"Size": int64(104857600),
|
||||
},
|
||||
},
|
||||
}
|
||||
media := r.MapRemoteItemToMedia(t.Context(), nil, &model.StrmAccount{Base: model.Base{ID: "acct-1"}}, &EmbyRemoteConfig{}, item)
|
||||
if media.Container != "mkv" {
|
||||
t.Fatalf("media.Container = %v, want mkv", media.Container)
|
||||
}
|
||||
if media.VideoCodec != "h264" {
|
||||
t.Fatalf("media.VideoCodec = %v, want h264", media.VideoCodec)
|
||||
}
|
||||
if media.AudioCodec != "aac" {
|
||||
t.Fatalf("media.AudioCodec = %v, want aac", media.AudioCodec)
|
||||
}
|
||||
if media.Width != 1920 || media.Height != 1080 {
|
||||
t.Fatalf("resolution = %dx%d, want 1920x1080", media.Width, media.Height)
|
||||
}
|
||||
if media.SizeBytes != 104857600 {
|
||||
t.Fatalf("size = %d, want 104857600", media.SizeBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
// 网页端远程 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
|
||||
}
|
||||
type accountData struct {
|
||||
acct *model.StrmAccount
|
||||
cfg *EmbyRemoteConfig
|
||||
viewByName map[string]map[string]any
|
||||
}
|
||||
acctData := map[string]*accountData{}
|
||||
for i := range mounts {
|
||||
m := &mounts[i]
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
if _, ok := acctData[m.AccountID]; ok {
|
||||
continue
|
||||
}
|
||||
acct := r.AccountByID(ctx, m.AccountID)
|
||||
if acct == nil {
|
||||
acctData[m.AccountID] = nil
|
||||
continue
|
||||
}
|
||||
cfg, cfgErr := r.configOf(acct)
|
||||
if cfgErr != nil {
|
||||
acctData[m.AccountID] = 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))
|
||||
}
|
||||
acctData[m.AccountID] = nil
|
||||
continue
|
||||
}
|
||||
viewByName := map[string]map[string]any{}
|
||||
for _, v := range views {
|
||||
viewByName[remoteItemString(v, "Id")] = v
|
||||
}
|
||||
acctData[m.AccountID] = &accountData{
|
||||
acct: acct,
|
||||
cfg: cfg,
|
||||
viewByName: viewByName,
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]RemoteLibraryView, 0, len(mounts))
|
||||
for i := range mounts {
|
||||
m := &mounts[i]
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
data := acctData[m.AccountID]
|
||||
if data == nil || data.viewByName == nil {
|
||||
continue
|
||||
}
|
||||
v, ok := data.viewByName[m.RemoteViewID]
|
||||
if !ok {
|
||||
continue // 远程已删除该媒体库
|
||||
}
|
||||
lib := r.mapRemoteMountToLibrary(m, data.acct, data.cfg, v)
|
||||
if lib == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, RemoteLibraryView{
|
||||
Library: *lib,
|
||||
MountID: m.ID,
|
||||
AccountID: data.acct.ID,
|
||||
RemoteID: m.RemoteViewID,
|
||||
CollectionType: m.CollectionType,
|
||||
AccountName: data.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 + mount.SortOrder, // 远程库排在本地库之后,且保持挂载库排序
|
||||
}
|
||||
// 远程媒体库封面只有真实存在图片标签才下发。
|
||||
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
|
||||
}
|
||||
}
|
||||
media.Container = remoteItemString(item, "Container")
|
||||
media.Width = remoteItemInt(item, "Width")
|
||||
media.Height = remoteItemInt(item, "Height")
|
||||
|
||||
extractStreamInfo := func(streams []any) {
|
||||
for _, s := range streams {
|
||||
sm, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ := remoteItemString(sm, "Type")
|
||||
if strings.EqualFold(typ, "Video") {
|
||||
if media.VideoCodec == "" {
|
||||
media.VideoCodec = remoteItemString(sm, "Codec")
|
||||
}
|
||||
if media.Width == 0 {
|
||||
media.Width = remoteItemInt(sm, "Width")
|
||||
}
|
||||
if media.Height == 0 {
|
||||
media.Height = remoteItemInt(sm, "Height")
|
||||
}
|
||||
} else if strings.EqualFold(typ, "Audio") {
|
||||
if media.AudioCodec == "" {
|
||||
media.AudioCodec = remoteItemString(sm, "Codec")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if streams, ok := item["MediaStreams"].([]any); ok {
|
||||
extractStreamInfo(streams)
|
||||
} else if streams, ok := item["MediaStreams"].([]map[string]any); ok {
|
||||
anyStreams := make([]any, len(streams))
|
||||
for i, v := range streams {
|
||||
anyStreams[i] = v
|
||||
}
|
||||
extractStreamInfo(anyStreams)
|
||||
}
|
||||
|
||||
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 0 {
|
||||
if sourceMap, ok := sources[0].(map[string]any); ok {
|
||||
if media.Container == "" {
|
||||
media.Container = remoteItemString(sourceMap, "Container")
|
||||
}
|
||||
if media.SizeBytes == 0 {
|
||||
media.SizeBytes = remoteItemInt64(sourceMap, "Size")
|
||||
}
|
||||
if streams, ok := sourceMap["MediaStreams"].([]any); ok && (media.VideoCodec == "" || media.AudioCodec == "") {
|
||||
extractStreamInfo(streams)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 分组)
|
||||
}
|
||||
cacheKey := r.remoteCacheKey("library-media", acct.ID, mount.ID, remoteViewID, itemTypes, strconv.Itoa(offset), strconv.Itoa(limit))
|
||||
var cached struct {
|
||||
Items []model.Media `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
}
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached.Items, cached.TotalRecordCount, nil
|
||||
}
|
||||
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,MediaStreams,MediaSources")
|
||||
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))
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, struct {
|
||||
Items []model.Media `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
}{Items: items, TotalRecordCount: body.TotalRecordCount}, r.remoteMediaCacheTTL())
|
||||
}
|
||||
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,MediaStreams,MediaSources"
|
||||
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,MediaStreams,MediaSources")
|
||||
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
|
||||
}
|
||||
cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID)
|
||||
var cached []SeriesCard
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
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})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
}
|
||||
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
|
||||
}
|
||||
cacheKey := r.remoteCacheKey("latest-cards", acct.ID, mount.ID, remoteViewID, strconv.Itoa(limit))
|
||||
var cached []SeriesCard
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
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})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
}
|
||||
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 int64:
|
||||
return int(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 int64:
|
||||
return 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") }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -84,3 +84,13 @@ func (p *ImageProxy) libraryRoots() []string {
|
||||
p.libRootsAt = time.Now()
|
||||
return p.libRootsCache
|
||||
}
|
||||
|
||||
// Prune removes oldest cached images until disk usage is within the configured limit.
|
||||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
|
||||
return PruneImageCacheResult{}, nil
|
||||
}
|
||||
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||
return PruneImageCache(p.cacheDir, maxBytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +106,18 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
cfg.App.SSLCert = value
|
||||
case "https.key":
|
||||
cfg.App.SSLKey = value
|
||||
case "https.cert_path":
|
||||
cfg.App.SSLCertPath = strings.TrimSpace(value)
|
||||
case "https.key_path":
|
||||
cfg.App.SSLKeyPath = strings.TrimSpace(value)
|
||||
}
|
||||
case "https.cert_path":
|
||||
cfg.App.SSLCertPath = strings.TrimSpace(value)
|
||||
case "https.key_path":
|
||||
cfg.App.SSLKeyPath = strings.TrimSpace(value)
|
||||
case "cache.images_max_size_mb":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
cfg.Cache.ImagesMaxSizeMB = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBoolSetting is the exported variant of parseBoolSetting for handlers
|
||||
|
||||
@@ -41,6 +41,8 @@ type SchedulerService struct {
|
||||
cacheDir string
|
||||
now func() time.Time
|
||||
|
||||
imagesMaxSizeMBProvider func() int
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
jobs []*scheduledJob
|
||||
@@ -59,6 +61,17 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService
|
||||
s.organizePipeline = pipeline
|
||||
}
|
||||
|
||||
func (s *SchedulerService) SetImagesMaxSizeMBProvider(fn func() int) {
|
||||
s.imagesMaxSizeMBProvider = fn
|
||||
}
|
||||
|
||||
func (s *SchedulerService) imagesMaxSizeMB() int {
|
||||
if s.imagesMaxSizeMBProvider != nil {
|
||||
return s.imagesMaxSizeMBProvider()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// scheduledJob is one recurring task.
|
||||
type scheduledJob struct {
|
||||
name string
|
||||
@@ -120,6 +133,11 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
interval: 24 * time.Hour,
|
||||
run: s.jobPurgeRecycleBin,
|
||||
},
|
||||
{
|
||||
name: "image_cache_cleanup",
|
||||
interval: 1 * time.Hour,
|
||||
run: s.jobCleanImageCache,
|
||||
},
|
||||
}
|
||||
for _, j := range s.jobs {
|
||||
initialDelay := 15 * time.Second
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -202,3 +203,32 @@ func isMissingTableErr(err error) bool {
|
||||
}
|
||||
return err == gorm.ErrInvalidDB
|
||||
}
|
||||
|
||||
// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit.
|
||||
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
if s.cacheDir == "" {
|
||||
return nil
|
||||
}
|
||||
maxMB := s.imagesMaxSizeMB()
|
||||
if maxMB <= 0 {
|
||||
return nil
|
||||
}
|
||||
imagesDir := filepath.Join(s.cacheDir, "images")
|
||||
maxSizeBytes := int64(maxMB) * 1024 * 1024
|
||||
res, err := PruneImageCache(imagesDir, maxSizeBytes)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("scheduled image cache cleanup failed", zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if res.DeletedFiles > 0 && s.log != nil {
|
||||
s.log.Info("scheduled image cache cleanup completed",
|
||||
zap.Int("deleted_files", res.DeletedFiles),
|
||||
zap.Int64("freed_bytes", res.FreedBytes),
|
||||
zap.Int64("remaining_bytes", res.RemainingBytes),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,11 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
@@ -103,6 +105,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).SetRuntimeCache(b.c.Cache)
|
||||
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)
|
||||
@@ -115,6 +119,28 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
|
||||
if !ok {
|
||||
return nil, "", errors.New("invalid emby remote id")
|
||||
}
|
||||
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", errors.New("emby mount or account not found")
|
||||
}
|
||||
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
||||
if err != nil || m == nil {
|
||||
return nil, "", err
|
||||
}
|
||||
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
return m, streamURL, nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
@@ -129,6 +155,12 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
)
|
||||
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
|
||||
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
||||
b.c.Scheduler.SetImagesMaxSizeMBProvider(func() int {
|
||||
if b.cfg == nil {
|
||||
return 0
|
||||
}
|
||||
return b.cfg.Cache.ImagesMaxSizeMB
|
||||
})
|
||||
}
|
||||
|
||||
func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
|
||||
@@ -86,9 +86,9 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
return STRMPlaybackEnabled(ctx, repo)
|
||||
}
|
||||
|
||||
// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
func isStrmMediaRow(m *model.Media) bool {
|
||||
func IsStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
@@ -101,6 +101,10 @@ func isStrmMediaRow(m *model.Media) bool {
|
||||
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm")
|
||||
}
|
||||
|
||||
func isStrmMediaRow(m *model.Media) bool {
|
||||
return IsStrmMediaRow(m)
|
||||
}
|
||||
|
||||
func isHTTPPlaybackTarget(raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil || !u.IsAbs() {
|
||||
|
||||
@@ -609,11 +609,26 @@ func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, erro
|
||||
return s.repo.StrmUpload.ClearCanceled(ctx)
|
||||
}
|
||||
|
||||
// ClearDoneUploadTasks 清空全部已完成上传记录,返回删除数量。
|
||||
func (s *StrmService) ClearDoneUploadTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.StrmUpload.ClearDone(ctx)
|
||||
}
|
||||
|
||||
// ClearFinishedUploadTasks 清空全部已完成与失败的上传记录,返回删除数量。
|
||||
func (s *StrmService) ClearFinishedUploadTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.StrmUpload.ClearFinished(ctx)
|
||||
}
|
||||
|
||||
// RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。
|
||||
func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.StrmDownload.RetryAllFailed(ctx)
|
||||
}
|
||||
|
||||
// RetryAllFailedUploadTasks 批量重试所有失败上传任务,返回重新入队数量。
|
||||
func (s *StrmService) RetryAllFailedUploadTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.StrmUpload.RetryAllFailed(ctx)
|
||||
}
|
||||
|
||||
// CancelPendingDownloadTasks 批量取消所有排队下载任务,返回取消数量。
|
||||
func (s *StrmService) CancelPendingDownloadTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.StrmDownload.CancelPending(ctx)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestIs115Blocked(t *testing.T) {
|
||||
@@ -44,3 +49,58 @@ func TestIsHTTPDownloadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrmUploadTasksClearAndRetry(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.StrmUploadTask{})
|
||||
repos := repository.New(db)
|
||||
svc := NewStrmService(nil, zap.NewNop(), repos, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
tasks := []*model.StrmUploadTask{
|
||||
{Base: model.Base{ID: "task-pending"}, Status: model.StrmTaskPending, FileName: "1.nfo"},
|
||||
{Base: model.Base{ID: "task-running"}, Status: model.StrmTaskRunning, FileName: "2.nfo"},
|
||||
{Base: model.Base{ID: "task-done"}, Status: model.StrmTaskDone, FileName: "3.nfo"},
|
||||
{Base: model.Base{ID: "task-failed"}, Status: model.StrmTaskFailed, FileName: "4.nfo", Error: "some error", RetryCount: 3},
|
||||
{Base: model.Base{ID: "task-canceled"}, Status: model.StrmTaskCanceled, FileName: "5.nfo"},
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if err := db.Create(task).Error; err != nil {
|
||||
t.Fatalf("failed to insert task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. RetryAllFailedUploadTasks
|
||||
retried, err := svc.RetryAllFailedUploadTasks(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RetryAllFailedUploadTasks failed: %v", err)
|
||||
}
|
||||
if retried != 1 {
|
||||
t.Fatalf("expected 1 retried task, got %d", retried)
|
||||
}
|
||||
var failedTask model.StrmUploadTask
|
||||
if err := db.First(&failedTask, "id = ?", "task-failed").Error; err != nil {
|
||||
t.Fatalf("failed to get task-failed: %v", err)
|
||||
}
|
||||
if failedTask.Status != model.StrmTaskPending || failedTask.Error != "" || failedTask.RetryCount != 0 {
|
||||
t.Fatalf("task-failed was not reset properly: %+v", failedTask)
|
||||
}
|
||||
|
||||
// 再次改为 failed 以便测试 ClearFinished
|
||||
db.Model(&model.StrmUploadTask{}).Where("id = ?", "task-failed").Updates(map[string]any{"status": model.StrmTaskFailed})
|
||||
|
||||
// 2. ClearFinishedUploadTasks 应删除 done, failed, canceled 三条历史记录
|
||||
deleted, err := svc.ClearFinishedUploadTasks(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ClearFinishedUploadTasks failed: %v", err)
|
||||
}
|
||||
if deleted != 3 {
|
||||
t.Fatalf("expected 3 deleted tasks (done, failed, canceled), got %d", deleted)
|
||||
}
|
||||
|
||||
// 验证剩余的任务只有 pending 和 running
|
||||
var count int64
|
||||
db.Model(&model.StrmUploadTask{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 remaining tasks, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,20 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container,
|
||||
visibility.IncludeNSFW = false
|
||||
}
|
||||
visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW)
|
||||
if userID == "" || repo.PlayProfile == nil {
|
||||
if userID == "" {
|
||||
return visibility
|
||||
}
|
||||
|
||||
if repo.User != nil {
|
||||
user, err := repo.User.FindByID(ctx, userID)
|
||||
if err == nil && user != nil && user.Role != "admin" {
|
||||
if userAllowed := user.DecodeAllowedLibraryIDs(); len(userAllowed) > 0 {
|
||||
visibility.AllowedLibraryIDs = userAllowed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if repo.PlayProfile == nil {
|
||||
return visibility
|
||||
}
|
||||
rows, err := repo.PlayProfile.ListByUser(ctx, userID)
|
||||
@@ -62,13 +75,44 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container,
|
||||
continue
|
||||
}
|
||||
visibility.IncludeNSFW = visibility.IncludeNSFW && row.AllowAdult
|
||||
visibility.AllowedLibraryIDs = DecodeAllowedLibraryIDs(row.AllowedLibraryIDs)
|
||||
profileAllowed := DecodeAllowedLibraryIDs(row.AllowedLibraryIDs)
|
||||
if len(profileAllowed) > 0 {
|
||||
if len(visibility.AllowedLibraryIDs) > 0 {
|
||||
visibility.AllowedLibraryIDs = IntersectStrings(visibility.AllowedLibraryIDs, profileAllowed)
|
||||
} else {
|
||||
visibility.AllowedLibraryIDs = profileAllowed
|
||||
}
|
||||
}
|
||||
visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW)
|
||||
break
|
||||
}
|
||||
return visibility
|
||||
}
|
||||
|
||||
// IntersectStrings 计算两个字符串切片的交集。
|
||||
func IntersectStrings(a, b []string) []string {
|
||||
if len(a) == 0 {
|
||||
return b
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return a
|
||||
}
|
||||
set := make(map[string]struct{}, len(b))
|
||||
for _, s := range b {
|
||||
set[s] = struct{}{}
|
||||
}
|
||||
var out []string
|
||||
for _, s := range a {
|
||||
if _, ok := set[s]; ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"__no_access__"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeAllowedLibraryIDs normalises a PlayProfile allowed-library JSON string.
|
||||
func DecodeAllowedLibraryIDs(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
|
||||
Binary file not shown.
@@ -79,6 +79,9 @@ export const adminAPI = {
|
||||
setUserStatus: (id: string, isActive: boolean) =>
|
||||
api.patch<User>(`/admin/users/${id}/status`, { is_active: isActive }).then((r) => r.data),
|
||||
|
||||
updateUserLibraries: (id: string, allowedLibraryIDs: string[] | null) =>
|
||||
api.patch<User>(`/admin/users/${id}/libraries`, { allowed_library_ids: allowedLibraryIDs }).then((r) => r.data),
|
||||
|
||||
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
|
||||
|
||||
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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),
|
||||
reorderMounts: (ids: string[]) =>
|
||||
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).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),
|
||||
}
|
||||
@@ -187,6 +187,15 @@ export const strmAPI = {
|
||||
cancelPendingUploads: () =>
|
||||
api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data),
|
||||
|
||||
clearDoneUploads: () =>
|
||||
api.post<{ deleted: number }>('/admin/strm/uploads/clear-done').then((r) => r.data),
|
||||
|
||||
clearFinishedUploads: () =>
|
||||
api.post<{ deleted: number }>('/admin/strm/uploads/clear-finished').then((r) => r.data),
|
||||
|
||||
clearCanceledUploads: () =>
|
||||
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
|
||||
|
||||
retryFailedUploads: () =>
|
||||
api.post<{ retried: number }>('/admin/strm/uploads/retry-failed').then((r) => r.data),
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -39,8 +39,8 @@ export function APIConfigsPanel() {
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll">
|
||||
<table className="min-w-[640px] w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200 text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
Film,
|
||||
FolderLock,
|
||||
Layers,
|
||||
Loader2,
|
||||
Square,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, User } from '../types'
|
||||
import { libraryDisplayPath } from '../pages/libraryDisplayModel'
|
||||
|
||||
type AdminUserLibrariesDialogProps = {
|
||||
user: User | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSaved: (updatedUser: User) => void
|
||||
}
|
||||
|
||||
export function AdminUserLibrariesDialog({
|
||||
user,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: AdminUserLibrariesDialogProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [mode, setMode] = useState<'all' | 'custom'>('all')
|
||||
const [selectedIDs, setSelectedIDs] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !user) return
|
||||
|
||||
setLoading(true)
|
||||
libraryAPI
|
||||
.list({ includeHidden: true })
|
||||
.then((libs) => {
|
||||
setLibraries(libs ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('加载媒体库列表失败')
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
const initialIDs = user.allowed_library_ids ?? []
|
||||
if (initialIDs.length > 0) {
|
||||
setMode('custom')
|
||||
setSelectedIDs(new Set(initialIDs))
|
||||
} else {
|
||||
setMode('all')
|
||||
setSelectedIDs(new Set())
|
||||
}
|
||||
}, [isOpen, user])
|
||||
|
||||
if (!isOpen || !user) return null
|
||||
|
||||
const toggleLibrary = (libID: string) => {
|
||||
setSelectedIDs((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(libID)) {
|
||||
next.delete(libID)
|
||||
} else {
|
||||
next.add(libID)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedIDs(new Set(libraries.map((l) => l.id)))
|
||||
}
|
||||
|
||||
const handleSelectNone = () => {
|
||||
setSelectedIDs(new Set())
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
let payloadIDs: string[] | null = null
|
||||
if (mode === 'custom') {
|
||||
const arr = Array.from(selectedIDs)
|
||||
// 如果勾选了全部,或者勾选为空
|
||||
if (arr.length === 0) {
|
||||
toast.error('请至少选择一个媒体库,或切换为“允许访问全部媒体库”')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
if (arr.length < libraries.length) {
|
||||
payloadIDs = arr
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await adminAPI.updateUserLibraries(user.id, payloadIDs)
|
||||
toast.success(
|
||||
payloadIDs && payloadIDs.length > 0
|
||||
? `已成功配置用户【${user.username}】仅可访问 ${payloadIDs.length} 个媒体库`
|
||||
: `已恢复用户【${user.username}】全库访问权限`,
|
||||
)
|
||||
onSaved(updated)
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存配置失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm animate-in fade-in-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-sand-100 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
|
||||
<FolderLock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">
|
||||
配置媒体库访问权限
|
||||
</h3>
|
||||
<p className="text-xs text-sand-500">
|
||||
用户:<span className="font-semibold text-ink-600">{user.username}</span>
|
||||
{user.role === 'admin' && (
|
||||
<span className="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-600 border border-amber-200">
|
||||
管理员拥有所有库访问权限
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-2 text-sand-500 hover:bg-sand-100 hover:text-ink-600 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
|
||||
{/* 模式选择 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('all')}
|
||||
className={`flex items-center gap-3 rounded-2xl border p-3.5 text-left transition-all ${
|
||||
mode === 'all'
|
||||
? 'border-brand-400 bg-brand-50/70 text-brand-800 shadow-sm ring-1 ring-brand-400'
|
||||
: 'border-sand-200 bg-white text-ink-600 hover:border-sand-300 hover:bg-sand-50/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${
|
||||
mode === 'all' ? 'bg-brand-500 text-white' : 'bg-sand-100 text-sand-500'
|
||||
}`}
|
||||
>
|
||||
<Layers size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-bold">全部媒体库 (默认)</div>
|
||||
<div className="text-[11px] text-sand-500">用户可访问系统所有已建媒体库</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('custom')
|
||||
if (selectedIDs.size === 0) {
|
||||
setSelectedIDs(new Set(libraries.map((l) => l.id)))
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-3 rounded-2xl border p-3.5 text-left transition-all ${
|
||||
mode === 'custom'
|
||||
? 'border-brand-400 bg-brand-50/70 text-brand-800 shadow-sm ring-1 ring-brand-400'
|
||||
: 'border-sand-200 bg-white text-ink-600 hover:border-sand-300 hover:bg-sand-50/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${
|
||||
mode === 'custom' ? 'bg-brand-500 text-white' : 'bg-sand-100 text-sand-500'
|
||||
}`}
|
||||
>
|
||||
<FolderLock size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-bold">指定可访问媒体库</div>
|
||||
<div className="text-[11px] text-sand-500">仅允许访问管理员勾选的媒体库</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 媒体库列表 */}
|
||||
{mode === 'custom' && (
|
||||
<div className="space-y-2 pt-2 border-t border-sand-100 animate-in fade-in-0 duration-200">
|
||||
<div className="flex items-center justify-between text-xs text-sand-500 px-1">
|
||||
<span>
|
||||
已选择{' '}
|
||||
<strong className="text-brand-600">{selectedIDs.size}</strong> /{' '}
|
||||
{libraries.length} 个媒体库
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
<CheckSquare size={13} />
|
||||
全选
|
||||
</button>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectNone}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-sand-500 hover:text-sand-700"
|
||||
>
|
||||
<Square size={13} />
|
||||
全不选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sand-500 text-xs gap-2">
|
||||
<Loader2 size={16} className="animate-spin text-brand-500" />
|
||||
正在加载媒体库…
|
||||
</div>
|
||||
) : libraries.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-sand-500 bg-sand-50/50 rounded-2xl">
|
||||
暂无可用媒体库
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{libraries.map((lib) => {
|
||||
const isChecked = selectedIDs.has(lib.id)
|
||||
const isTv =
|
||||
lib.type === 'tv' || lib.type === 'anime' || lib.type === 'variety'
|
||||
return (
|
||||
<div
|
||||
key={lib.id}
|
||||
onClick={() => toggleLibrary(lib.id)}
|
||||
className={`group flex cursor-pointer items-center justify-between rounded-xl border p-3 transition-all ${
|
||||
isChecked
|
||||
? 'border-brand-300 bg-brand-50/40 shadow-xs'
|
||||
: 'border-sand-200 bg-white hover:border-sand-300 hover:bg-sand-50/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
isChecked
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-sand-100 text-sand-500 group-hover:text-sand-700'
|
||||
}`}
|
||||
>
|
||||
{isTv ? <Tv size={15} /> : <Film size={15} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-ink-600">
|
||||
{lib.name}
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-[10px] text-sand-500"
|
||||
title={lib.path}
|
||||
>
|
||||
{lib.type} · {libraryDisplayPath(lib.path)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors ${
|
||||
isChecked
|
||||
? 'border-brand-500 bg-brand-500 text-white'
|
||||
: 'border-sand-300 bg-white group-hover:border-sand-400'
|
||||
}`}
|
||||
>
|
||||
{isChecked && <Check size={12} className="stroke-[3]" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2.5 border-t border-sand-100 bg-sand-50/60 px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-sand-200 bg-white px-4 py-2 text-xs font-semibold text-ink-600 hover:bg-sand-100 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || loading}
|
||||
onClick={handleSave}
|
||||
className="neon-button inline-flex items-center gap-2 px-5 py-2 text-xs font-semibold disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 size={13} className="animate-spin" />}
|
||||
<span>保存配置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export function ExternalPlayerButton({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title={compact ? '使用外部播放器播放' : undefined}
|
||||
disabled={loading}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -50,7 +51,7 @@ export function ExternalPlayerButton({
|
||||
}}
|
||||
className={
|
||||
compact
|
||||
? 'rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50'
|
||||
? 'shrink-0 inline-flex items-center rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50 transition-colors whitespace-nowrap'
|
||||
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
|
||||
}
|
||||
>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function Layout() {
|
||||
const isPlayPage = location.pathname.startsWith('/play')
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
<LayoutSidebars
|
||||
sidebar={sidebar}
|
||||
isAdmin={permissions.isAdmin}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function LayoutHeader({
|
||||
hideSearch,
|
||||
}: LayoutHeaderProps) {
|
||||
return (
|
||||
<header className="relative flex h-20 shrink-0 items-center justify-between gap-4 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
|
||||
{/* Left: Mobile Menu button or Brand Logo */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
@@ -71,7 +71,7 @@ export function LayoutHeader({
|
||||
</div>
|
||||
|
||||
{/* Middle: Search Box */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
<div className="flex min-w-0 flex-1 max-w-xl mx-auto">
|
||||
{!hideSearch && <LayoutHeaderSearch />}
|
||||
</div>
|
||||
|
||||
@@ -155,8 +155,8 @@ function LayoutHeaderSearch() {
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3.5 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500"
|
||||
size={15}
|
||||
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -169,8 +169,8 @@ function LayoutHeaderSearch() {
|
||||
if (results.length > 0) setIsOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索电影、剧集、演员…"
|
||||
className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
placeholder="搜索媒体…"
|
||||
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
|
||||
@@ -288,8 +288,10 @@ function LayoutHeaderActions({
|
||||
}: LayoutHeaderActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
|
||||
<div className="hidden items-center gap-3 sm:flex md:gap-4">
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
<span className="h-6 w-px bg-[var(--app-border)]" />
|
||||
</div>
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isOpen={isProfileOpen}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Check, Shuffle } from 'lucide-react'
|
||||
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
type SortField,
|
||||
type SortOption,
|
||||
type SortOrder,
|
||||
} from '../utils/mediaSort'
|
||||
|
||||
type MediaSortDropdownProps = {
|
||||
value: SortField
|
||||
order: SortOrder
|
||||
onChange: (field: SortField, order: SortOrder) => void
|
||||
onReshuffle?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MediaSortDropdown({
|
||||
value,
|
||||
order,
|
||||
onChange,
|
||||
onReshuffle,
|
||||
className = '',
|
||||
}: MediaSortDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentOption = SORT_OPTIONS.find((opt) => opt.id === value) ?? SORT_OPTIONS[0]
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (option: SortOption) => {
|
||||
if (option.id === 'random') {
|
||||
if (value === 'random') {
|
||||
onReshuffle?.()
|
||||
} else {
|
||||
onChange('random', 'desc')
|
||||
}
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (value === option.id) {
|
||||
// 再次点击相同项:切换升降序
|
||||
const nextOrder: SortOrder = order === 'asc' ? 'desc' : 'asc'
|
||||
onChange(option.id, nextOrder)
|
||||
} else {
|
||||
// 点击新项:应用默认排序方向
|
||||
onChange(option.id, option.defaultOrder)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700"
|
||||
title="更改排序方式"
|
||||
>
|
||||
<ArrowUpDown size={14} className="text-sand-500" />
|
||||
<span>排序: {currentOption.label}</span>
|
||||
{value === 'random' ? (
|
||||
<Shuffle size={12} className="text-brand-600" />
|
||||
) : order === 'asc' ? (
|
||||
<ArrowUp size={13} className="text-brand-600 font-bold" />
|
||||
) : (
|
||||
<ArrowDown size={13} className="text-brand-600 font-bold" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 z-50 mt-1.5 w-52 origin-top-right rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95">
|
||||
<div className="px-2.5 py-1.5 text-[11px] font-bold text-sand-500 border-b border-sand-100">
|
||||
排序方式
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto py-1 space-y-0.5">
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const isSelected = value === option.id
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-50 text-brand-700 font-semibold'
|
||||
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="flex items-center text-brand-600">
|
||||
{option.id === 'random' ? (
|
||||
<Shuffle size={13} />
|
||||
) : order === 'asc' ? (
|
||||
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -324,7 +324,7 @@ export function PlayerControls({
|
||||
onMouseLeave={() => setControlsHovered(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-white">
|
||||
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
|
||||
{/* 上一集 */}
|
||||
<button
|
||||
onClick={onPrevEpisode}
|
||||
@@ -362,24 +362,25 @@ export function PlayerControls({
|
||||
<SkipForward size={18} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={displayTime}
|
||||
onMouseDown={handleSeekStart}
|
||||
onTouchStart={handleSeekStart}
|
||||
onChange={(e) => handleSeekChange(Number(e.target.value))}
|
||||
onMouseUp={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
onTouchEnd={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
className="min-w-0 flex-1 cursor-pointer accent-rose-500"
|
||||
aria-label="播放进度"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-white/85">
|
||||
{formatTime(displayTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
<div className="order-2 flex min-w-0 basis-full items-center gap-2 sm:order-none sm:basis-auto sm:flex-1">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={displayTime}
|
||||
onMouseDown={handleSeekStart}
|
||||
onTouchStart={handleSeekStart}
|
||||
onChange={(e) => handleSeekChange(Number(e.target.value))}
|
||||
onMouseUp={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
onTouchEnd={(e) => handleSeekEnd(Number((e.target as HTMLInputElement).value))}
|
||||
className="min-w-0 flex-1 cursor-pointer accent-rose-500"
|
||||
aria-label="播放进度"
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-white/85 sm:text-xs">
|
||||
{formatTime(displayTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pipSupported && (
|
||||
<button
|
||||
@@ -456,7 +457,7 @@ export function PlayerControls({
|
||||
title={hasPlaylist ? '选集列表' : '当前无更多剧集'}
|
||||
>
|
||||
<ListVideo size={15} />
|
||||
选集
|
||||
<span className="hidden sm:inline">选集</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -472,7 +473,7 @@ export function PlayerControls({
|
||||
title="弹幕设置"
|
||||
>
|
||||
<MessageSquareText size={15} />
|
||||
弹幕
|
||||
<span className="hidden sm:inline">弹幕</span>
|
||||
{danmakuEnabled && <span className="h-1.5 w-1.5 rounded-full bg-lime-400" />}
|
||||
</button>
|
||||
|
||||
@@ -490,7 +491,7 @@ export function PlayerControls({
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => changeVolume(Number(e.target.value))}
|
||||
className="w-16 accent-rose-500"
|
||||
className="hidden w-16 accent-rose-500 sm:block"
|
||||
aria-label="音量"
|
||||
/>
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ export function PlayerDanmakuPanel({
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 z-30 w-80 rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md"
|
||||
className="absolute inset-x-3 top-12 bottom-3 z-30 w-auto overflow-y-auto rounded-2xl border border-white/15 bg-black/85 p-4 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-auto sm:w-80"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PlayerPlaylistPanel({
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-4 top-16 bottom-20 z-30 flex w-80 sm:w-96 flex-col rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md overflow-hidden"
|
||||
className="absolute inset-x-3 top-12 bottom-3 z-30 flex flex-col overflow-hidden rounded-2xl border border-white/15 bg-black/85 text-white shadow-2xl backdrop-blur-md sm:inset-x-auto sm:right-4 sm:top-16 sm:bottom-20 sm:w-96"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -22,17 +22,17 @@ export function openManageLibrariesDialog(): Promise<void> {
|
||||
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -42,7 +42,7 @@ function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+3
-2
@@ -277,10 +277,11 @@ body {
|
||||
.badge-neutral { @apply badge bg-gray-100 text-gray-600 border border-gray-200/50; }
|
||||
|
||||
/* ── Page Headings ── */
|
||||
.page-heading { @apply font-display text-3xl font-extrabold tracking-tight text-gray-900; }
|
||||
.page-heading { @apply font-display text-2xl font-extrabold tracking-tight text-gray-900 sm:text-3xl; }
|
||||
.page-subtitle { @apply mt-1 text-sm text-gray-500; }
|
||||
|
||||
/* ── Data Tables Redesign ── */
|
||||
/* ── Mobile Data Tables ── */
|
||||
.table-scroll { @apply overflow-x-auto overscroll-x-contain; -webkit-overflow-scrolling: touch; }
|
||||
.data-table { @apply w-full text-left text-sm border-collapse; }
|
||||
.data-table thead { @apply border-b border-gray-200 bg-gray-50/70; }
|
||||
.data-table th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500 whitespace-nowrap; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { adminAPI } from '../api/admin'
|
||||
import type { User } from '../types'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { requestPassword } from '../components/requestPassword'
|
||||
import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog'
|
||||
import { AdminUsersForm } from './AdminUsersForm'
|
||||
import { AdminUsersTable } from './AdminUsersTable'
|
||||
|
||||
@@ -15,6 +16,8 @@ export function AdminUsersPanel() {
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [editingUsername, setEditingUsername] = useState('')
|
||||
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
||||
const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
setUsers(await adminAPI.listUsers())
|
||||
}
|
||||
@@ -148,9 +151,20 @@ export function AdminUsersPanel() {
|
||||
onCancelEdit={() => setEditingID(null)}
|
||||
onStartEdit={startEdit}
|
||||
onResetPassword={resetPassword}
|
||||
onConfigureLibraries={(u) => setConfiguringLibrariesUser(u)}
|
||||
onToggleStatus={toggleStatus}
|
||||
onDeleteUser={deleteUser}
|
||||
/>
|
||||
|
||||
<AdminUserLibrariesDialog
|
||||
user={configuringLibrariesUser}
|
||||
isOpen={Boolean(configuringLibrariesUser)}
|
||||
onClose={() => setConfiguringLibrariesUser(null)}
|
||||
onSaved={async (updated) => {
|
||||
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)))
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { KeyRound, Loader2, Pencil, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
|
||||
import {
|
||||
FolderLock,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserX,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { User } from '../types'
|
||||
|
||||
@@ -12,6 +22,7 @@ type AdminUsersTableProps = {
|
||||
onCancelEdit: () => void
|
||||
onStartEdit: (user: User) => void
|
||||
onResetPassword: (user: User) => void
|
||||
onConfigureLibraries: (user: User) => void
|
||||
onToggleStatus: (user: User) => void
|
||||
onDeleteUser: (user: User) => void
|
||||
}
|
||||
@@ -26,16 +37,18 @@ export function AdminUsersTable({
|
||||
onCancelEdit,
|
||||
onStartEdit,
|
||||
onResetPassword,
|
||||
onConfigureLibraries,
|
||||
onToggleStatus,
|
||||
onDeleteUser,
|
||||
}: AdminUsersTableProps) {
|
||||
return (
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll">
|
||||
<table className="min-w-[900px] w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
<th>角色</th>
|
||||
<th>媒体库权限</th>
|
||||
<th>状态</th>
|
||||
<th>权限说明</th>
|
||||
<th>最近登录</th>
|
||||
@@ -60,6 +73,27 @@ export function AdminUsersTable({
|
||||
)}
|
||||
</td>
|
||||
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
|
||||
<td>
|
||||
{u.role === 'admin' ? (
|
||||
<span className="inline-flex items-center rounded-full bg-sand-100 px-2.5 py-0.5 text-xs text-sand-600 font-medium">
|
||||
全库 (管理员)
|
||||
</span>
|
||||
) : !u.allowed_library_ids || u.allowed_library_ids.length === 0 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-green-50 border border-green-200/80 px-2.5 py-0.5 text-xs font-medium text-green-700">
|
||||
全部媒体库 (默认)
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfigureLibraries(u)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-50 border border-brand-300 px-2.5 py-0.5 text-xs font-semibold text-brand-700 hover:bg-brand-100/70 transition-colors"
|
||||
title="点击修改媒体库访问权限"
|
||||
>
|
||||
<FolderLock size={12} />
|
||||
<span>已指定 {u.allowed_library_ids.length} 个库</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
|
||||
{u.is_active ? '正常' : '已禁用'}
|
||||
</td>
|
||||
@@ -92,11 +126,19 @@ export function AdminUsersTable({
|
||||
) : (
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
title="重命名用户"
|
||||
onClick={() => onStartEdit(u)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg border border-brand-400/40 px-2 py-1 text-xs text-brand-600 hover:bg-brand-400/10"
|
||||
title="配置媒体库访问权限"
|
||||
onClick={() => onConfigureLibraries(u)}
|
||||
>
|
||||
<FolderLock size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
|
||||
title="重置密码"
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
Globe,
|
||||
GripVertical,
|
||||
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-1 gap-3 sm:grid-cols-2">
|
||||
<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 [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const [reordering, setReordering] = useState(false)
|
||||
|
||||
const applyReorder = async (nextMounts: EmbyMount[]) => {
|
||||
const prevMounts = mounts
|
||||
setMounts(nextMounts)
|
||||
setReordering(true)
|
||||
try {
|
||||
await embyAPI.reorderMounts(nextMounts.map((m) => m.id))
|
||||
toast.success('媒体库顺序已更新')
|
||||
} catch (err) {
|
||||
setMounts(prevMounts)
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setReordering(false)
|
||||
}
|
||||
}
|
||||
|
||||
const moveMount = (m: EmbyMount, direction: 'up' | 'down') => {
|
||||
const acctMounts = mounts.filter((item) => item.account_id === m.account_id)
|
||||
const idx = acctMounts.findIndex((item) => item.id === m.id)
|
||||
if (idx < 0) return
|
||||
if (direction === 'up' && idx === 0) return
|
||||
if (direction === 'down' && idx === acctMounts.length - 1) return
|
||||
|
||||
const targetIdx = direction === 'up' ? idx - 1 : idx + 1
|
||||
const newAcctMounts = [...acctMounts]
|
||||
const [moved] = newAcctMounts.splice(idx, 1)
|
||||
newAcctMounts.splice(targetIdx, 0, moved)
|
||||
|
||||
let acctPointer = 0
|
||||
const nextMounts = mounts.map((item) => {
|
||||
if (item.account_id === m.account_id) {
|
||||
return newAcctMounts[acctPointer++]
|
||||
}
|
||||
return item
|
||||
})
|
||||
applyReorder(nextMounts)
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDraggingId(id)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, overId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId !== overId) {
|
||||
setDragOverId(overId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (overId: string, accountId: string) => {
|
||||
if (!draggingId || draggingId === overId) {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
return
|
||||
}
|
||||
const acctMounts = mounts.filter((item) => item.account_id === accountId)
|
||||
const fromIdx = acctMounts.findIndex((item) => item.id === draggingId)
|
||||
const toIdx = acctMounts.findIndex((item) => item.id === overId)
|
||||
if (fromIdx < 0 || toIdx < 0) {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
return
|
||||
}
|
||||
const newAcctMounts = [...acctMounts]
|
||||
const [moved] = newAcctMounts.splice(fromIdx, 1)
|
||||
newAcctMounts.splice(toIdx, 0, moved)
|
||||
|
||||
let acctPointer = 0
|
||||
const nextMounts = mounts.map((item) => {
|
||||
if (item.account_id === accountId) {
|
||||
return newAcctMounts[acctPointer++]
|
||||
}
|
||||
return item
|
||||
})
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
applyReorder(nextMounts)
|
||||
}
|
||||
|
||||
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, index) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onDragOver={(e) => handleDragOver(e, m.id)}
|
||||
onDrop={() => handleDrop(m.id, acct.id)}
|
||||
className={`flex items-center gap-3 rounded-xl border px-3 py-3 transition-colors ${
|
||||
draggingId === m.id
|
||||
? 'border-dashed border-brand-400 bg-brand-50/20 opacity-50'
|
||||
: dragOverId === m.id
|
||||
? 'border-brand-400 bg-brand-50/30'
|
||||
: m.enabled
|
||||
? 'border-sand-200 bg-gray-50/60'
|
||||
: 'border-gray-100 bg-gray-50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, m.id)}
|
||||
onDragEnd={() => {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
}}
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing text-sand-400 hover:text-ink-600 p-0.5"
|
||||
title="按住拖拽调整顺序"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => moveMount(m, 'up')}
|
||||
disabled={index === 0 || reordering}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveMount(m, 'down')}
|
||||
disabled={index === acctMounts.length - 1 || reordering}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDown size={13} />
|
||||
</button>
|
||||
</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
|
||||
@@ -26,8 +26,8 @@ export function FileEntriesTable({
|
||||
onChoose,
|
||||
}: FileEntriesTableProps) {
|
||||
return (
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll">
|
||||
<table className="min-w-[700px] w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 py-2">
|
||||
|
||||
@@ -48,36 +48,44 @@ export function LibrariesHeader({
|
||||
onManageLibraries: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
|
||||
<p className="mt-1 text-sm text-ink-50">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">媒体库</h1>
|
||||
<p className="mt-1 text-xs text-ink-50 sm:text-sm">
|
||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{repairMsg && <span className="text-xs text-ink-50">{repairMsg}</span>}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
onChange={onRepairEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-10"
|
||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||
>
|
||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link to="/scraper/queue" className="btn-outline inline-flex items-center gap-1.5" title="查看正在进行的刮削任务与进度">
|
||||
<Link
|
||||
to="/scraper/queue"
|
||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="查看正在进行的刮削任务与进度"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>刮削队列</span>
|
||||
</Link>
|
||||
<button type="button" onClick={onManageLibraries} className="btn-outline">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageLibraries}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
管理媒体库
|
||||
</button>
|
||||
</div>
|
||||
@@ -102,7 +110,7 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
<p className="text-sm text-ink-50">按目录进入完整媒体库;下方每个目录也会直接展示最新内容。</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview.library.id}
|
||||
@@ -142,9 +150,9 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
className="group flex overflow-hidden rounded-2xl sm:rounded-3xl border border-sand-200 bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
>
|
||||
<div className={`grid h-24 w-36 shrink-0 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
@@ -159,25 +167,32 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 flex h-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[library.type] ?? <FolderOpen size={34} />}
|
||||
{TYPE_ICONS[library.type] ?? <FolderOpen size={28} className="sm:h-8 sm:w-8" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between px-4 py-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between px-3 sm:px-4 py-0.5 sm:py-1">
|
||||
<div>
|
||||
<div className="mb-1 inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
<div className="mb-1 flex items-center justify-between gap-1.5">
|
||||
<span className="inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-sand-500 sm:hidden">
|
||||
{preview.total.toLocaleString()} 个条目
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="truncate font-display text-xl font-black text-ink-600 group-hover:text-brand-600">
|
||||
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
|
||||
{library.name}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50" title={library.path}>
|
||||
<p className="mt-0.5 line-clamp-1 break-all text-[11px] text-ink-50 sm:mt-1 sm:text-xs" title={library.path}>
|
||||
{displayPath}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs font-bold">
|
||||
<span className="text-sand-600">{preview.total.toLocaleString()} 个条目</span>
|
||||
<span className="text-brand-600">浏览全部</span>
|
||||
<div className="mt-1 flex items-center justify-between text-xs font-bold sm:mt-0">
|
||||
<span className="hidden text-sand-600 sm:inline">{preview.total.toLocaleString()} 个条目</span>
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[11px] text-brand-600 sm:text-xs">
|
||||
浏览全部 <ArrowRight size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -190,19 +205,19 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.7rem] border border-sand-200 bg-white/75 p-4 shadow-card">
|
||||
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<section className="rounded-2xl sm:rounded-[1.7rem] border border-sand-200 bg-white/75 p-3.5 sm:p-4 shadow-card">
|
||||
<div className="mb-3 sm:mb-4 flex flex-wrap items-end justify-between gap-2 sm:gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 inline-flex items-center gap-2 rounded-full bg-brand-50 px-2.5 py-1 text-[11px] font-bold text-brand-700">
|
||||
<div className="mb-1 inline-flex items-center gap-1.5 sm:gap-2 rounded-full bg-brand-50 px-2 sm:px-2.5 py-0.5 sm:py-1 text-[10px] sm:text-[11px] font-bold text-brand-700">
|
||||
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</div>
|
||||
<h2 className="truncate font-display text-2xl font-black text-ink-600">{library.name}</h2>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">{library.name}</h2>
|
||||
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50">
|
||||
<span title={library.path}>{displayPath}</span> · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部
|
||||
</p>
|
||||
</div>
|
||||
<Link to={`/library/${library.id}`} className="btn-outline shrink-0">
|
||||
<Link to={`/library/${library.id}`} className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm shrink-0">
|
||||
浏览全部
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
|
||||
@@ -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,10 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { Media } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
sortMediaList,
|
||||
sortSeriesList,
|
||||
type SortField,
|
||||
type SortOrder,
|
||||
} from '../utils/mediaSort'
|
||||
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||
@@ -25,6 +32,53 @@ export function LibraryPage() {
|
||||
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||
|
||||
// 排序状态(支持按库记忆)
|
||||
const [sortField, setSortField] = useState<SortField>(() => {
|
||||
const saved = localStorage.getItem(`mmtl_lib_sort_field_${id}`) || localStorage.getItem('mmtl_lib_sort_field')
|
||||
return (saved as SortField) || 'title'
|
||||
})
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(() => {
|
||||
const saved = localStorage.getItem(`mmtl_lib_sort_order_${id}`) || localStorage.getItem('mmtl_lib_sort_order')
|
||||
return (saved as SortOrder) || 'asc'
|
||||
})
|
||||
const [randomSeed, setRandomSeed] = useState(() => Date.now())
|
||||
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
historyAPI
|
||||
.list(1000)
|
||||
.then((historyItems) => {
|
||||
const map = new Map<string, string>()
|
||||
for (const item of historyItems ?? []) {
|
||||
if (item.media_id && item.watched_at) {
|
||||
if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) {
|
||||
map.set(item.media_id, item.watched_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
setHistoryMap(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSortChange = (field: SortField, order: SortOrder) => {
|
||||
setSortField(field)
|
||||
setSortOrder(order)
|
||||
if (id) {
|
||||
localStorage.setItem(`mmtl_lib_sort_field_${id}`, field)
|
||||
localStorage.setItem(`mmtl_lib_sort_order_${id}`, order)
|
||||
}
|
||||
localStorage.setItem('mmtl_lib_sort_field', field)
|
||||
localStorage.setItem('mmtl_lib_sort_order', order)
|
||||
if (field === 'random') {
|
||||
setRandomSeed(Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
const handleReshuffle = () => {
|
||||
setRandomSeed(Date.now())
|
||||
}
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||
@@ -43,6 +97,14 @@ export function LibraryPage() {
|
||||
reloadCurrentLibrary,
|
||||
} = useLibraryData(id, selectedSeries)
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap)
|
||||
}, [items, sortField, sortOrder, randomSeed, historyMap])
|
||||
|
||||
const sortedSeriesCards = useMemo(() => {
|
||||
return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap)
|
||||
}, [seriesCards, sortField, sortOrder, randomSeed, historyMap])
|
||||
|
||||
const {
|
||||
scanning,
|
||||
scanProgress,
|
||||
@@ -66,7 +128,7 @@ export function LibraryPage() {
|
||||
isSeriesLibrary,
|
||||
isSeries,
|
||||
loading,
|
||||
seriesCards,
|
||||
seriesCards: sortedSeriesCards,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
selectedSeries,
|
||||
@@ -116,7 +178,7 @@ export function LibraryPage() {
|
||||
{!selectedSeries && (
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? seriesCards.length : total}
|
||||
itemCount={isSeries ? sortedSeriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
@@ -124,6 +186,10 @@ export function LibraryPage() {
|
||||
scanning={scanning}
|
||||
scraping={scraping}
|
||||
repairing={repairing}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onReshuffle={handleReshuffle}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={() => setScrapeDialogOpen(true)}
|
||||
@@ -133,8 +199,8 @@ export function LibraryPage() {
|
||||
|
||||
<LibraryMediaSections
|
||||
isSeries={isSeries}
|
||||
items={items}
|
||||
seriesCards={seriesCards}
|
||||
items={sortedItems}
|
||||
seriesCards={sortedSeriesCards}
|
||||
selectedSeries={selectedSeries}
|
||||
loading={loading}
|
||||
movieActions={movieActions}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Library } from '../types'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import { MediaSortDropdown } from '../components/MediaSortDropdown'
|
||||
import type { SortField, SortOrder } from '../utils/mediaSort'
|
||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||
|
||||
type LibraryPageHeaderProps = {
|
||||
@@ -12,6 +14,10 @@ type LibraryPageHeaderProps = {
|
||||
scanning: boolean
|
||||
scraping: boolean
|
||||
repairing: boolean
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
onSortChange: (field: SortField, order: SortOrder) => void
|
||||
onReshuffle: () => void
|
||||
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||
onScan: () => void
|
||||
onScrape: () => void
|
||||
@@ -28,6 +34,10 @@ export function LibraryPageHeader({
|
||||
scanning,
|
||||
scraping,
|
||||
repairing,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onReshuffle,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
onScan,
|
||||
onScrape,
|
||||
@@ -36,40 +46,57 @@ export function LibraryPageHeader({
|
||||
const displayPath = library ? libraryDisplayPath(library.path) : ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">
|
||||
{library?.name ?? '媒体库'}
|
||||
<span className="text-sand-500"> ({itemCount})</span>
|
||||
</h1>
|
||||
{library && <p className="text-sm text-ink-50" title={library.path}>{library.type} · {displayPath}</p>}
|
||||
{library && <p className="text-xs text-ink-50 sm:text-sm" title={library.path}>{library.type} · {displayPath}</p>}
|
||||
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
|
||||
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-10"
|
||||
/>
|
||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||
</button>
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MediaSortDropdown
|
||||
value={sortField}
|
||||
order={sortOrder}
|
||||
onChange={onSortChange}
|
||||
onReshuffle={onReshuffle}
|
||||
/>
|
||||
{isAdmin && !library?.is_remote_emby && (
|
||||
<>
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-9 text-xs sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={onScan}
|
||||
disabled={scanning}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onScrape}
|
||||
disabled={scraping}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="对整个媒体库执行刮削元数据"
|
||||
>
|
||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -62,48 +62,53 @@ export function LibrarySeriesEpisodes({
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50"
|
||||
title={ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : episodeDisplayTitle(ep, visibleEpisodes)}
|
||||
>
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-brand-600 font-semibold text-sm">{ep.episode_num || '—'}</span>
|
||||
)}
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="absolute bottom-0 right-0 rounded-tl bg-black/70 px-1 py-px text-[9px] font-bold text-white">
|
||||
{ep.episode_num}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{episodeDisplayTitle(ep, visibleEpisodes)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center justify-between gap-3 rounded-xl border border-sand-200 bg-white p-2.5 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className="relative flex h-11 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-brand-50/70 border border-sand-200/60"
|
||||
title={displayTitle}
|
||||
>
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-brand-600 font-bold text-sm">{ep.episode_num || '—'}</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Play size={15} className="fill-white text-white drop-shadow-sm" />
|
||||
</div>
|
||||
{ep.episode_num > 0 && (
|
||||
<span className="absolute bottom-0 right-0 rounded-tl bg-black/75 px-1 py-0.5 text-[9px] font-bold leading-none text-white backdrop-blur-[2px]">
|
||||
{ep.episode_num}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600 transition-colors group-hover:text-brand-600" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500 whitespace-nowrap">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -111,17 +116,29 @@ export function LibrarySeriesEpisodes({
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
let mainTitle = ''
|
||||
const epTitle = ep.episode_title?.trim()
|
||||
if (epTitle && !looksLikeSeriesTitle(ep, epTitle, siblings)) {
|
||||
mainTitle = epTitle
|
||||
} else {
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
mainTitle = mediaTitle
|
||||
}
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
if (ep.episode_num > 0) {
|
||||
if (!mainTitle) {
|
||||
return `第 ${ep.episode_num} 集`
|
||||
}
|
||||
const prefixRegex = new RegExp(`^(第\\s*0*${ep.episode_num}\\s*集|ep?\\.?\\s*0*${ep.episode_num}\\b)`, 'i')
|
||||
if (prefixRegex.test(mainTitle)) {
|
||||
return mainTitle
|
||||
}
|
||||
return `第 ${ep.episode_num} 集 · ${mainTitle}`
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
return mainTitle || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
|
||||
@@ -83,8 +83,8 @@ export function LibraryStorageStats() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-display text-lg font-semibold text-ink-600">按媒体库</h3>
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full text-left text-sm">
|
||||
<div className="glass-panel table-scroll !p-3">
|
||||
<table className="min-w-[500px] w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
|
||||
@@ -171,8 +171,8 @@ export function ManualOrganizePanel({
|
||||
|
||||
function ManualOrganizePreviewTable({ items }: { items: PreviewItem[] }) {
|
||||
return (
|
||||
<div className="max-h-72 overflow-auto rounded-xl border border-gray-200 bg-white/70">
|
||||
<table className="w-full text-left text-xs">
|
||||
<div className="table-scroll max-h-72 overflow-y-auto rounded-xl border border-gray-200 bg-white/70">
|
||||
<table className="min-w-[700px] w-full text-left text-xs">
|
||||
<thead className="sticky top-0 bg-white text-sand-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">动作</th>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -50,6 +50,7 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
|
||||
|
||||
<Link
|
||||
to={`/play/${media.id}`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
|
||||
import type { Media } from '../types'
|
||||
import { isDirectStreamMedia } from './playerPageModel'
|
||||
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
|
||||
import { MediaDetailPoster } from './MediaDetailArtwork'
|
||||
import { MediaDetailMetadata } from './MediaDetailMetadata'
|
||||
@@ -90,18 +91,21 @@ export function MediaDetailPlaybackActions({
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
<Link to={`/play/${media.id}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
{!isDirectStreamMedia(media) && (
|
||||
<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} />
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, isEpisodeLike, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
@@ -98,9 +99,6 @@ export function PlayerPage() {
|
||||
const backTarget = useCallback(() => {
|
||||
const state = location.state as { from?: string } | null
|
||||
if (state?.from) return state.from
|
||||
if (media && isEpisodeLike(media) && media.library_id) {
|
||||
return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}`
|
||||
}
|
||||
const target = media?.id || id
|
||||
return target ? `/media/${target}` : '/'
|
||||
}, [id, location.state, media])
|
||||
@@ -189,10 +187,11 @@ export function PlayerPage() {
|
||||
if (!id) return
|
||||
mediaAPI.get(id).then((m) => {
|
||||
setMedia(m)
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const forced = params.get('mode') as PlayerMode | null
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码模式下忽略 ?mode=hls 与自动判定,始终 direct play。
|
||||
setMode(directOnly ? 'direct' : (forced ?? auto))
|
||||
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
|
||||
setMode(directOnly || isDirect ? 'direct' : (forced ?? auto))
|
||||
setPlayerError('')
|
||||
})
|
||||
subtitlesAPI
|
||||
@@ -432,12 +431,18 @@ export function PlayerPage() {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [goBack, prevEpisode, nextEpisode, handlePrevEpisode, handleNextEpisode, playlistOpen, danmakuOpen])
|
||||
|
||||
const isDirectStream = isDirectStreamMedia(media)
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
if (isDirectStream) {
|
||||
toast('该媒体为直连播放,无需且不支持转码')
|
||||
return
|
||||
}
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}, [mode, params, setParams])
|
||||
}, [isDirectStream, mode, params, setParams])
|
||||
|
||||
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
||||
const selectSubtitle = useCallback((index: number) => {
|
||||
@@ -453,7 +458,13 @@ export function PlayerPage() {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (directOnly) {
|
||||
if (isRemoteEmbyID(media?.id)) {
|
||||
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (isDirectStreamMedia(media)) {
|
||||
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
} else if (hlsUnavailable) {
|
||||
@@ -470,12 +481,20 @@ export function PlayerPage() {
|
||||
|
||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
}, [directOnly, hlsUnavailable, mode, params, setParams])
|
||||
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
|
||||
<PlayerTopBar
|
||||
directOnly={directOnly}
|
||||
isDirectStream={isDirectStream}
|
||||
directStreamLabel={
|
||||
isRemoteEmbyID(media?.id)
|
||||
? 'Emby 直连播放'
|
||||
: isDirectStream
|
||||
? 'STRM 直连播放'
|
||||
: undefined
|
||||
}
|
||||
mode={mode}
|
||||
onBack={goBack}
|
||||
onToggleMode={toggleMode}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PlayerMode } from './playerPageModel'
|
||||
|
||||
type PlayerTopBarProps = {
|
||||
directOnly: boolean
|
||||
isDirectStream?: boolean
|
||||
directStreamLabel?: string
|
||||
mode: PlayerMode
|
||||
onBack: () => void
|
||||
onToggleMode: () => void
|
||||
@@ -11,6 +13,8 @@ type PlayerTopBarProps = {
|
||||
|
||||
export function PlayerTopBar({
|
||||
directOnly,
|
||||
isDirectStream,
|
||||
directStreamLabel,
|
||||
mode,
|
||||
onBack,
|
||||
onToggleMode,
|
||||
@@ -25,7 +29,14 @@ export function PlayerTopBar({
|
||||
</button>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
{directOnly ? (
|
||||
{isDirectStream ? (
|
||||
<span
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title={directStreamLabel ? `${directStreamLabel},默认直连播放,不进行转码` : '远程直连播放,不进行转码'}
|
||||
>
|
||||
<Sparkles size={14} /> {directStreamLabel || '直连播放'}
|
||||
</span>
|
||||
) : directOnly ? (
|
||||
<span
|
||||
className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title="宿主机不转码,由客户端本地解码直连"
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ProfileFormModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="glass-panel max-h-[calc(100dvh-2rem)] w-full max-w-lg overflow-y-auto !p-4 sm:!p-6">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑 Profile' : '创建 Profile'}
|
||||
</h2>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function ProfilePreferenceFields({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="首选字幕">
|
||||
<select
|
||||
className="input-base"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user