Compare commits

..

16 Commits

Author SHA1 Message Date
github-actions[bot] 1025e3c693 chore: bump version to 0.0.88 [skip ci] 2026-09-02 15:05:55 +00:00
truewhile ba27d48d10 优化 2026-09-02 23:05:38 +08:00
github-actions[bot] 6d20dc65de chore: bump version to 0.0.87 [skip ci] 2026-09-02 14:15:40 +00:00
truewhile d4c82ac07e 优化 2026-09-02 22:15:24 +08:00
github-actions[bot] acdb7b2ead chore: bump version to 0.0.86 [skip ci] 2026-09-02 12:56:27 +00:00
truewhile 32ae1aeba9 优化 2026-09-02 20:56:09 +08:00
github-actions[bot] 55badfa6ad chore: bump version to 0.0.85 [skip ci] 2026-09-02 12:38:08 +00:00
truewhile 8754ee615f 优化 2026-09-02 20:37:54 +08:00
github-actions[bot] 4943d28743 chore: bump version to 0.0.84 [skip ci] 2026-09-02 11:08:22 +00:00
truewhile ee2272f743 优化 2026-09-02 19:08:06 +08:00
github-actions[bot] e4f41c0b3d chore: bump version to 0.0.83 [skip ci] 2026-09-02 11:00:16 +00:00
truewhile a02583ee7d 优化 2026-09-02 19:00:01 +08:00
github-actions[bot] e079a3cbb2 chore: bump version to 0.0.82 [skip ci] 2026-09-02 10:26:53 +00:00
truewhile 5cd1aa3e91 优化 2026-09-02 18:26:31 +08:00
github-actions[bot] e923c9d7a1 chore: bump version to 0.0.81 [skip ci] 2026-09-02 09:10:18 +00:00
truewhile bbb512760a feat: make user limit configurable from admin user management (#18)
Store the per-instance user cap in settings (default 20) and expose
GET/PUT /admin/users/limit endpoints. The user management page now lets
admins view and update the limit without touching system settings.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 17:10:07 +08:00
48 changed files with 2134 additions and 105 deletions
+39
View File
@@ -264,3 +264,42 @@ jobs:
if [ -f "$TAR" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done
fi
deploy:
name: Deploy to Server
needs: [version-and-publish]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
# 判断 compose 命令版本兼容性(docker compose 或 docker-compose)
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
+38
View File
@@ -61,3 +61,41 @@ jobs:
VERSION=${{ env.RELEASE_VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to Server
needs: [docker]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
+1 -1
View File
@@ -1 +1 @@
0.0.80
0.0.88
+66 -2
View File
@@ -31,7 +31,66 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc {
for i := range users {
users[i].PopulateComputedFields()
}
c.JSON(http.StatusOK, users)
maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"users": users,
"max_users": maxUsers,
"current_users": len(users),
})
}
}
type updateUserLimitReq struct {
MaxUsers int `json:"max_users" binding:"required"`
}
func getUserLimitHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentUsers, err := svc.Repo.User.Count(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"max_users": maxUsers,
"current_users": currentUsers,
})
}
}
func updateUserLimitHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req updateUserLimitReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := service.SaveMaxUsers(c.Request.Context(), svc.Repo, req.MaxUsers); err != nil {
if errors.Is(err, service.ErrInvalidMaxUsers) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentUsers, err := svc.Repo.User.Count(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"max_users": req.MaxUsers,
"current_users": currentUsers,
})
}
}
@@ -288,7 +347,12 @@ func writeUserMutationError(c *gin.Context, svc *service.Container, err error) {
case errors.Is(err, service.ErrUsernameTaken):
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
case errors.Is(err, service.ErrUserLimitReached):
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit})
maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestUserLimitHandlers(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)
cfg := &service.Container{Repo: repos, Auth: service.NewAuthService(nil, zap.NewNop(), repos, service.NewTokenService(nil, zap.NewNop(), repos), service.NewPermissionService(zap.NewNop(), repos))}
router := gin.New()
router.GET("/admin/users/limit", getUserLimitHandler(cfg))
router.PUT("/admin/users/limit", updateUserLimitHandler(cfg))
req := httptest.NewRequest(http.MethodGet, "/admin/users/limit", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET limit status = %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"max_users":20`) {
t.Fatalf("expected default max_users=20, got %s", w.Body.String())
}
req = httptest.NewRequest(http.MethodPut, "/admin/users/limit", strings.NewReader(`{"max_users":42}`))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PUT limit status = %d body=%s", w.Code, w.Body.String())
}
got, err := service.LoadMaxUsers(t.Context(), repos)
if err != nil || got != 42 {
t.Fatalf("stored max users = %d err=%v, want 42", got, err)
}
}
+9
View File
@@ -68,6 +68,15 @@ func registerHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
return
}
if errors.Is(err, service.ErrUserLimitReached) {
maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+5 -2
View File
@@ -213,10 +213,13 @@ func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String())
}
var users []model.User
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
var payload struct {
Users []model.User `json:"users"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode users: %v", err)
}
users := payload.Users
if len(users) != 1 {
t.Fatalf("users = %#v", users)
}
+68 -9
View File
@@ -452,55 +452,114 @@ func updateMediaMetadataHandler(svc *service.Container) gin.HandlerFunc {
}
}
func paginateSlice[T any](items []T, page, size int) []T {
if page < 1 {
page = 1
}
if size <= 0 {
size = 50
}
if len(items) == 0 {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
}
end := start + size
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
fetchRemote := func(limit int) []model.Media {
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
return nil
}
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if !groupVersions {
items, total, err := svc.Media.SearchMediaVisiblePage(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc))
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
items, total, err := svc.Media.SearchMediaVisiblePageGrouped(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc))
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
paged := service.PaginateMediaItems(grouped, page, size)
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"items": paged,
"total": len(grouped),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if !groupVersions {
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
if len(all) > limit {
all = all[:limit]
}
if all == nil {
all = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
items, err := svc.Media.SearchMediaVisibleGrouped(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
+174 -2
View File
@@ -436,8 +436,180 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
if strings.Contains(body, `"items":null`) {
t.Fatalf("%s: empty library returned items:null (crashes frontend): %s", tc.name, body)
}
if !strings.Contains(body, `"items":[]`) {
t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body)
if !strings.Contains(body, `"items":[]`) {
t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body)
}
}
}
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
gin.SetMode(gin.TestMode)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("SearchTerm") == "碧蓝之海" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{
{
"Id": "156030",
"Name": "碧蓝之海",
"Type": "Series",
"ProductionYear": 2018,
},
},
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 0,
"Items": []map[string]any{},
})
}))
defer server.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
adminUser := model.User{
Base: model.Base{ID: "user-1"},
Username: "admin",
Role: "admin",
}
_ = repos.DB.Create(&adminUser).Error
localLib := model.Library{Name: "本地电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &localLib); err != nil {
t.Fatal(err)
}
localMedia := model.Media{
Base: model.Base{ID: "local-1"},
LibraryID: localLib.ID,
Title: "流浪地球",
Year: 2019,
}
if err := repos.DB.Create(&localMedia).Error; err != nil {
t.Fatal(err)
}
rawCfg, _ := json.Marshal(map[string]string{"url": server.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawCfg),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
mount := model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "动漫",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
crypto := service.NewCryptoService("", zap.NewNop())
remoteSvc := service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, crypto)
mediaSvc := service.NewMediaService(&config.Config{}, zap.NewNop(), repos)
svc := &service.Container{
Repo: repos,
Media: mediaSvc,
EmbyRemote: remoteSvc,
}
// 1. 搜索远程挂载媒体(碧蓝之海)
{
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=碧蓝之海&limit=8", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(res.Items))
}
if res.Items[0].Title != "碧蓝之海" {
t.Fatalf("expected Title '碧蓝之海', got %q", res.Items[0].Title)
}
expectedID := service.EncodeEmbyRemoteID("mount-1", "156030")
if res.Items[0].ID != expectedID {
t.Fatalf("expected ID %q, got %q", expectedID, res.Items[0].ID)
}
}
// 2. 搜索本地媒体(流浪地球)
{
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=流浪地球&limit=8", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(res.Items))
}
if res.Items[0].Title != "流浪地球" {
t.Fatalf("expected Title '流浪地球', got %q", res.Items[0].Title)
}
}
// 3. 搜索不存在的媒体
{
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=不存在的影片&limit=8", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 0 {
t.Fatalf("expected 0 items, got %d", len(res.Items))
}
if strings.Contains(w.Body.String(), `"items":null`) {
t.Fatalf("expected items:[], got null: %s", w.Body.String())
}
}
}
+5
View File
@@ -37,6 +37,7 @@ func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container)
admin.POST("/scraper/queue/clear-done", clearDoneScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-finished", clearFinishedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-canceled", clearCanceledScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-failed", clearFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/retry-failed", retryAllFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/cancel-pending", cancelPendingScrapeTasksHandler(svc))
admin.POST("/scraper/queue/enqueue-library/:id", enqueueLibraryScrapeHandler(svc))
@@ -87,6 +88,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-failed", clearFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/retry-failed", retryAllFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/cancel-pending", cancelPendingDownloadsHandler(svc))
admin.GET("/strm/uploads", uploadQueueHandler(svc))
@@ -97,11 +99,14 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
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/clear-failed", clearFailedUploadsHandler(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) {
admin.GET("/users/limit", getUserLimitHandler(svc))
admin.PUT("/users/limit", updateUserLimitHandler(svc))
admin.GET("/users", listUsersHandler(svc))
admin.POST("/users", createUserHandler(svc))
admin.PATCH("/users/:id", updateUserHandler(svc))
+11
View File
@@ -101,6 +101,17 @@ func clearCanceledScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearFailedScrapeTasks(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 retryAllFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.RetryAllFailedScrapeTasks(c.Request.Context())
+23 -2
View File
@@ -7,6 +7,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -16,16 +17,26 @@ import (
// searchUnifiedHandler is the basic /api/search endpoint.
func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
if limit <= 0 || limit > 200 {
limit = 30
}
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
visibility := mediaVisibilityForRequest(c, svc)
items, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote != nil && strings.TrimSpace(q) != "" {
if remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility); len(remoteItems) > 0 {
items = append(items, remoteItems...)
if len(items) > limit {
items = items[:limit]
}
}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
}
@@ -36,16 +47,26 @@ func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
// them back. This keeps API parity without a giant query builder.
func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
if limit <= 0 || limit > 200 {
limit = 30
}
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
visibility := mediaVisibilityForRequest(c, svc)
items, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote != nil && strings.TrimSpace(q) != "" {
if remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility); len(remoteItems) > 0 {
items = append(items, remoteItems...)
if len(items) > limit {
items = items[:limit]
}
}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"filters": gin.H{
+22
View File
@@ -526,6 +526,17 @@ func clearCanceledDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearFailedDownloadTasks(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 clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearCanceledUploadTasks(c.Request.Context())
@@ -537,6 +548,17 @@ func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearFailedUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearFailedUploadTasks(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 clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context())
+22 -17
View File
@@ -8,6 +8,7 @@ package handler
import (
"net/http"
"runtime"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -22,26 +23,30 @@ import (
// without threading state through the container.
var startedAt = time.Now()
func systemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
directOnly := false
if svc.Repo != nil && svc.Repo.Setting != nil {
if v, err := svc.Repo.Setting.Get(c.Request.Context(), service.PlaybackDirectOnlySettingKey); err == nil {
directOnly = service.ParseBoolSetting(v, false)
func systemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
directOnly := false
if svc.Repo != nil && svc.Repo.Setting != nil {
if v, err := svc.Repo.Setting.Get(c.Request.Context(), service.PlaybackDirectOnlySettingKey); err == nil {
directOnly = service.ParseBoolSetting(v, false)
}
}
version := svc.Version
if strings.TrimSpace(version) == "" {
version = "dev"
}
c.JSON(http.StatusOK, gin.H{
"name": "MeBox",
"version": version,
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"data_dir": svc.Cfg.App.DataDir,
"cache_dir": svc.Cfg.Cache.CacheDir,
"direct_play_only": directOnly,
})
}
c.JSON(http.StatusOK, gin.H{
"name": "MeBox",
"version": "0.1.0",
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"data_dir": svc.Cfg.App.DataDir,
"cache_dir": svc.Cfg.Cache.CacheDir,
"direct_play_only": directOnly,
})
}
}
func systemStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -236,6 +236,16 @@ func (r *ScrapeTaskRepository) ClearCanceled(ctx context.Context) (int64, error)
return count, err
}
func (r *ScrapeTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskFailed).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
+22
View File
@@ -402,6 +402,17 @@ func (r *StrmDownloadTaskRepository) ClearCanceled(ctx context.Context) (int64,
return count, err
}
// ClearFailed 清空全部已失败下载任务。
func (r *StrmDownloadTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskFailed).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmDownloadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
@@ -709,6 +720,17 @@ func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, er
return count, err
}
// ClearFailed 清空全部已失败上传任务。
func (r *StrmUploadTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskFailed).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
+7 -9
View File
@@ -40,14 +40,8 @@ var (
ErrUserExpired = errors.New("user account has expired")
)
// MaxUsers 是单实例允许的最大用户数(开源版本固定上限)。
const MaxUsers = OpenSourceUserLimit
// OpenSourceUserLimit 是开源版本的用户数上限。
const OpenSourceUserLimit = 20
// UserLimit 是注册/邀请码发放时的固定用户数上限(授权管理已移除,固定为开源上限)。
const UserLimit = OpenSourceUserLimit
// MaxUsers is kept for tests that seed up to the default cap.
const MaxUsers = DefaultMaxUsers
// SeedAdmin makes sure at least one admin user exists. It mirrors the
// legacy default behaviour: if no admin row is found we create
@@ -103,9 +97,13 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil {
return nil, nil, err
}
limit, err := LoadMaxUsers(ctx, s.repo)
if err != nil {
return nil, nil, err
}
if n, err := s.repo.User.Count(ctx); err != nil {
return nil, nil, err
} else if n >= UserLimit {
} else if n >= int64(limit) {
return nil, nil, ErrUserLimitReached
}
hash, err := hashPassword(password)
+1 -1
View File
@@ -77,7 +77,7 @@ func (c *CryptoService) Encrypt(plain string) string {
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
// (no prefix) are returned unchanged.
func (c *CryptoService) Decrypt(value string) string {
if value == "" || c.aead == nil {
if c == nil || value == "" || c.aead == nil {
return value
}
if !strings.HasPrefix(value, encPrefix) {
+146 -8
View File
@@ -13,6 +13,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -300,11 +301,22 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
media.SeasonNum = 0
media.EpisodeNum = 0
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
media.DisplayLibraryID = libID
media.LibraryID = libID
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
media.DisplayLibraryID = libID
media.LibraryID = libID
libName := strings.TrimSpace(mount.Name)
if libName == "" {
libName = strings.TrimSpace(mount.RemoteViewName)
}
if libName == "" && acct != nil {
libName = acct.Name
} else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) {
libName = acct.Name + " · " + libName
}
media.LibraryName = libName
media.DisplayLibraryName = libName
}
return media
}
@@ -502,10 +514,136 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
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())
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
return cards, nil
// RemoteSearchMedia 在全部启用的挂载库中并发搜索影视条目(Movie,Series),
// 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。
func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) {
if r == nil {
return nil, nil
}
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
if limit <= 0 {
limit = 50
} else if limit > maxMediaSearchLimit {
limit = maxMediaSearchLimit
}
mounts, err := r.ListMounts(ctx)
if err != nil || len(mounts) == 0 {
return nil, err
}
type mountTarget struct {
mount model.EmbyMount
acct *model.StrmAccount
cfg *EmbyRemoteConfig
}
var targets []mountTarget
for _, m := range mounts {
if !m.Enabled {
continue
}
libID := EncodeEmbyRemoteID(m.ID, m.RemoteViewID)
hidden := false
for _, hid := range visibility.HiddenLibraryIDs {
if hid == libID {
hidden = true
break
}
}
if hidden {
continue
}
if len(visibility.AllowedLibraryIDs) > 0 {
allowed := false
for _, aid := range visibility.AllowedLibraryIDs {
if aid == libID {
allowed = true
break
}
}
if !allowed {
continue
}
}
acct := r.AccountByID(ctx, m.AccountID)
if acct == nil {
continue
}
cfg, cfgErr := r.configOf(acct)
if cfgErr != nil {
continue
}
targets = append(targets, mountTarget{mount: m, acct: acct, cfg: cfg})
}
if len(targets) == 0 {
return nil, nil
}
searchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
sem := make(chan struct{}, 6)
var wg sync.WaitGroup
type searchResult struct {
items []model.Media
}
results := make([]searchResult, len(targets))
for i, t := range targets {
wg.Add(1)
go func(idx int, target mountTarget) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-searchCtx.Done():
return
}
q := url.Values{}
q.Set("ParentId", target.mount.RemoteViewID)
q.Set("Recursive", "true")
q.Set("SearchTerm", query)
q.Set("IncludeItemTypes", "Movie,Series")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
q.Set("Limit", strconv.Itoa(limit))
q.Set("StartIndex", "0")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
if r.log != nil {
r.log.Warn("remote search failed",
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
}
return
}
medias := make([]model.Media, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, target.mount.ID)
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
medias = append(medias, m)
}
results[idx] = searchResult{items: medias}
}(i, t)
}
wg.Wait()
var out []model.Media
for _, res := range results {
out = append(out, res.items...)
}
return out, nil
}
// WebStreamURL 远程条目的网页播放地址(302 直连远程 Emby 流端点)。
+118 -2
View File
@@ -2,10 +2,17 @@ package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestMapRemoteItemToMediaSortingFields(t *testing.T) {
@@ -61,7 +68,116 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) {
if media.Rating != 9.2 {
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
}
if media.Year != 2022 {
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
if media.Year != 2022 {
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
}
}
func TestRemoteSearchMedia(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("SearchTerm") == "碧蓝之海" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{
{
"Id": "156030",
"Name": "碧蓝之海",
"Type": "Series",
"ProductionYear": 2018,
},
},
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 0,
"Items": []map[string]any{},
})
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "fake-token",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "test-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "动漫",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
// 1. 正常搜索
items, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatalf("RemoteSearchMedia failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].Title != "碧蓝之海" {
t.Fatalf("expected Title '碧蓝之海', got %q", items[0].Title)
}
expectedID := EncodeEmbyRemoteID("mount-1", "156030")
if items[0].ID != expectedID {
t.Fatalf("expected ID %q, got %q", expectedID, items[0].ID)
}
// 2. 搜索不到的内容
notFound, err := svc.RemoteSearchMedia(t.Context(), "其它不存在的剧", 10, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatalf("RemoteSearchMedia failed: %v", err)
}
if len(notFound) != 0 {
t.Fatalf("expected 0 items, got %d", len(notFound))
}
// 3. 白名单过滤:当白名单不包含该挂载虚拟库 ID 时应过滤掉
allowedLibID := "local-lib-1"
filtered, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{
IncludeNSFW: true,
AllowedLibraryIDs: []string{allowedLibID},
})
if err != nil {
t.Fatalf("RemoteSearchMedia with allowed filter failed: %v", err)
}
if len(filtered) != 0 {
t.Fatalf("expected 0 items due to AllowedLibraryIDs, got %d", len(filtered))
}
// 4. 黑名单过滤:当黑名单包含该挂载虚拟库 ID 时应过滤掉
mountLibID := EncodeEmbyRemoteID("mount-1", "view-1")
hiddenFiltered, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{
IncludeNSFW: true,
HiddenLibraryIDs: []string{mountLibID},
})
if err != nil {
t.Fatalf("RemoteSearchMedia with hidden filter failed: %v", err)
}
if len(hiddenFiltered) != 0 {
t.Fatalf("expected 0 items due to HiddenLibraryIDs, got %d", len(hiddenFiltered))
}
}
+41
View File
@@ -44,6 +44,11 @@ func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
return items[start:end]
}
// PaginateMediaItems 导出分页辅助函数。
func PaginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
return paginateMediaItems(items, page, pageSize)
}
func firstMediaItems(items []MediaItem, limit int) []MediaItem {
if len(items) == 0 {
return nil
@@ -60,6 +65,11 @@ func firstMediaItems(items []MediaItem, limit int) []MediaItem {
return items[:limit]
}
// FirstMediaItems 导出截取前 N 项辅助函数。
func FirstMediaItems(items []MediaItem, limit int) []MediaItem {
return firstMediaItems(items, limit)
}
func groupMediaVersions(items []model.Media) []MediaItem {
if len(items) == 0 {
return nil
@@ -104,7 +114,17 @@ func groupMediaVersions(items []model.Media) []MediaItem {
return out
}
// GroupMediaVersions 导出多版本分组函数。
func GroupMediaVersions(items []model.Media) []MediaItem {
return groupMediaVersions(items)
}
func mediaVersionGroupKey(m model.Media) string {
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
switch {
case m.TMDbID > 0:
@@ -131,14 +151,32 @@ func mediaVersionGroupKey(m model.Media) string {
fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum),
}, "|")
}
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
}
switch {
case m.TMDbID > 0:
if libKey != "" {
return fmt.Sprintf("movie:%s:tmdb:%d", libKey, m.TMDbID)
}
return fmt.Sprintf("tmdb:%d", m.TMDbID)
case m.BangumiID > 0:
if libKey != "" {
return fmt.Sprintf("movie:%s:bangumi:%d", libKey, m.BangumiID)
}
return fmt.Sprintf("bangumi:%d", m.BangumiID)
case strings.TrimSpace(m.DoubanID) != "":
if libKey != "" {
return fmt.Sprintf("movie:%s:douban:%s", libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
}
return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
case strings.TrimSpace(m.TheTVDBID) != "":
if libKey != "" {
return fmt.Sprintf("movie:%s:thetvdb:%s", libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
}
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
}
title := firstNonEmpty(m.OriginalName, m.Title)
@@ -158,6 +196,9 @@ func mediaVersionGroupKey(m model.Media) string {
if year <= 0 {
_, year = CleanQuery(m.Path)
}
if libKey != "" {
return fmt.Sprintf("movie:%s:%s:%d", libKey, title, year)
}
return fmt.Sprintf("movie:%s:%d", title, year)
}
+46
View File
@@ -218,3 +218,49 @@ func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T
t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path)
}
}
func TestGroupMediaVersionsDoesNotMergeDifferentRemoteSeries(t *testing.T) {
// 模拟来自两个不同挂载库(如 00 新番连载 与 2018 动漫)的同名剧集《碧蓝之海》
season1 := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID("mount-1", "156019")},
LibraryID: EncodeEmbyRemoteID("mount-1", "view-1"),
Title: "碧蓝之海",
Year: 2018,
LibraryName: "2018 动漫",
}
season2 := model.Media{
Base: model.Base{ID: EncodeEmbyRemoteID("mount-2", "156030")},
LibraryID: EncodeEmbyRemoteID("mount-2", "view-2"),
Title: "碧蓝之海",
Year: 2024,
LibraryName: "00 新番连载",
}
grouped := groupMediaVersions([]model.Media{season1, season2})
if len(grouped) != 2 {
t.Fatalf("expected 2 separate groups for different remote series/libraries, got %d: %#v", len(grouped), grouped)
}
if grouped[0].Title != "碧蓝之海" || grouped[1].Title != "碧蓝之海" {
t.Fatalf("expected both titles to be '碧蓝之海'")
}
}
func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *testing.T) {
movieLib1 := model.Media{
Base: model.Base{ID: "m-1"},
LibraryID: "lib-1",
Title: "碧蓝之海",
Year: 2018,
}
movieLib2 := model.Media{
Base: model.Base{ID: "m-2"},
LibraryID: "lib-2",
Title: "碧蓝之海",
Year: 2018,
}
grouped := groupMediaVersions([]model.Media{movieLib1, movieLib2})
if len(grouped) != 2 {
t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped))
}
}
+4
View File
@@ -345,6 +345,10 @@ func (s *ScraperService) ClearCanceledScrapeTasks(ctx context.Context) (int64, e
return s.repo.ScrapeTask.ClearCanceled(ctx)
}
func (s *ScraperService) ClearFailedScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.ClearFailed(ctx)
}
func (s *ScraperService) RetryAllFailedScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.RetryAllFailed(ctx)
}
+1
View File
@@ -15,6 +15,7 @@ import (
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
type Container struct {
Version string
Cfg *config.Config
Log *zap.Logger
Repo *repository.Container
+7 -6
View File
@@ -27,12 +27,13 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
cfg: cfg,
log: log,
repos: repos,
version: normalizeSystemUpdateVersion(version),
c: &Container{
Cfg: cfg,
Log: log,
Repo: repos,
},
version: normalizeSystemUpdateVersion(version),
c: &Container{
Version: normalizeSystemUpdateVersion(version),
Cfg: cfg,
Log: log,
Repo: repos,
},
}
builder.startRealtimeServices()
builder.initProviderServices()
+10
View File
@@ -610,11 +610,21 @@ func (s *StrmService) ClearCanceledDownloadTasks(ctx context.Context) (int64, er
return s.repo.StrmDownload.ClearCanceled(ctx)
}
// ClearFailedDownloadTasks 清空全部已失败的下载记录,返回删除数量。
func (s *StrmService) ClearFailedDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearFailed(ctx)
}
// ClearCanceledUploadTasks 清空全部已取消的上传记录,返回删除数量。
func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearCanceled(ctx)
}
// ClearFailedUploadTasks 清空全部已失败的上传记录,返回删除数量。
func (s *StrmService) ClearFailedUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearFailed(ctx)
}
// ClearDoneUploadTasks 清空全部已完成上传记录,返回删除数量。
func (s *StrmService) ClearDoneUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearDone(ctx)
+71
View File
@@ -0,0 +1,71 @@
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/repository"
)
const (
// SettingMaxUsers stores the per-instance user cap in the settings table.
SettingMaxUsers = "user.max_users"
// DefaultMaxUsers is used when the setting is missing or invalid.
DefaultMaxUsers = 20
// MaxUsersHardCap prevents absurd values from admin input.
MaxUsersHardCap = 10000
)
var (
ErrInvalidMaxUsers = errors.New("invalid max users")
)
// LoadMaxUsers reads the configured user cap, falling back to DefaultMaxUsers.
func LoadMaxUsers(ctx context.Context, repo *repository.Container) (int, error) {
if repo == nil || repo.Setting == nil {
return DefaultMaxUsers, nil
}
raw, err := repo.Setting.Get(ctx, SettingMaxUsers)
if err != nil {
return DefaultMaxUsers, err
}
return parseMaxUsersSetting(raw), nil
}
func parseMaxUsersSetting(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 1 {
return DefaultMaxUsers
}
if n > MaxUsersHardCap {
return MaxUsersHardCap
}
return n
}
// ValidateMaxUsers checks an admin-provided cap before persisting it.
func ValidateMaxUsers(n int) error {
if n < 1 {
return fmt.Errorf("%w: must be at least 1", ErrInvalidMaxUsers)
}
if n > MaxUsersHardCap {
return fmt.Errorf("%w: must be at most %d", ErrInvalidMaxUsers, MaxUsersHardCap)
}
return nil
}
// SaveMaxUsers persists the user cap in settings.
func SaveMaxUsers(ctx context.Context, repo *repository.Container, n int) error {
if err := ValidateMaxUsers(n); err != nil {
return err
}
if repo == nil || repo.Setting == nil {
return errors.New("settings repository unavailable")
}
return repo.Setting.Set(ctx, SettingMaxUsers, strconv.Itoa(n))
}
+67
View File
@@ -0,0 +1,67 @@
package service
import (
"context"
"fmt"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestLoadMaxUsersDefaultsToTwenty(t *testing.T) {
ctx := context.Background()
repos, _, _, _ := newAuthTestServices(t)
got, err := LoadMaxUsers(ctx, repos)
if err != nil {
t.Fatal(err)
}
if got != DefaultMaxUsers {
t.Fatalf("max users = %d, want %d", got, DefaultMaxUsers)
}
}
func TestSaveAndLoadMaxUsers(t *testing.T) {
ctx := context.Background()
repos, _, _, _ := newAuthTestServices(t)
if err := SaveMaxUsers(ctx, repos, 50); err != nil {
t.Fatal(err)
}
got, err := LoadMaxUsers(ctx, repos)
if err != nil {
t.Fatal(err)
}
if got != 50 {
t.Fatalf("max users = %d, want 50", got)
}
}
func TestValidateMaxUsersRejectsInvalid(t *testing.T) {
if err := ValidateMaxUsers(0); err == nil {
t.Fatal("expected error for 0")
}
if err := ValidateMaxUsers(MaxUsersHardCap + 1); err == nil {
t.Fatal("expected error above hard cap")
}
}
func TestRegisterRespectsConfiguredUserLimit(t *testing.T) {
ctx := context.Background()
repos, auth, _, _ := newAuthTestServices(t)
if err := SaveMaxUsers(ctx, repos, 3); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if err := repos.User.Create(ctx, &model.User{
Username: fmt.Sprintf("user-%02d", i),
PasswordHash: "hash",
Role: "user",
Tier: "free",
}); err != nil {
t.Fatal(err)
}
}
_, _, err := auth.Register(ctx, "overflow", "password")
if err == nil {
t.Fatal("expected user limit error")
}
}
+24 -4
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, MIGRATION_REQUEST_TIMEOUT } from './client'
import type { AccessLog, Setting, User } from '../types'
export interface DatabaseStatus {
@@ -64,8 +64,24 @@ export interface SystemUpdateStatus {
started_at?: string
}
export interface UserListResponse {
users: User[]
max_users: number
current_users: number
}
export interface UserLimitResponse {
max_users: number
current_users: number
}
export const adminAPI = {
listUsers: () => api.get<User[]>('/admin/users').then((r) => r.data),
listUsers: () => api.get<UserListResponse>('/admin/users').then((r) => r.data),
getUserLimit: () => api.get<UserLimitResponse>('/admin/users/limit').then((r) => r.data),
updateUserLimit: (maxUsers: number) =>
api.put<UserLimitResponse>('/admin/users/limit', { max_users: maxUsers }).then((r) => r.data),
createUser: (payload: { username: string; password: string }) =>
api.post<User>('/admin/users', payload).then((r) => r.data),
@@ -121,8 +137,12 @@ export const adminAPI = {
testDatabaseConnection: (payload: DatabaseConnectionPayload) =>
api.post<PostgresTestResult>('/admin/database/test', payload).then((r) => r.data),
migrateDatabase: (payload: DatabaseConnectionPayload) =>
api.post<DatabaseMigrationResult>('/admin/database/migrate', payload).then((r) => r.data),
migrateDatabase: (payload: DatabaseConnectionPayload) =>
api
.post<DatabaseMigrationResult>('/admin/database/migrate', payload, {
timeout: MIGRATION_REQUEST_TIMEOUT,
})
.then((r) => r.data),
saveDatabaseConfig: (payload: DatabaseConnectionPayload) =>
api.post<{ message: string; type: string }>('/admin/database/save-config', payload).then((r) => r.data),
+1
View File
@@ -12,6 +12,7 @@ export const api = axios.create({
export const LONG_REQUEST_TIMEOUT = 120_000
export const BATCH_REQUEST_TIMEOUT = 300_000
export const MIGRATION_REQUEST_TIMEOUT = 600_000
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false
+3
View File
@@ -39,6 +39,9 @@ export const scraperAPI = {
clearCanceled: () =>
api.post<{ deleted: number }>('/admin/scraper/queue/clear-canceled').then((r) => r.data),
clearFailed: () =>
api.post<{ deleted: number }>('/admin/scraper/queue/clear-failed').then((r) => r.data),
retryFailed: () =>
api.post<{ retried: number }>('/admin/scraper/queue/retry-failed').then((r) => r.data),
+6
View File
@@ -159,6 +159,9 @@ export const strmAPI = {
clearCanceledDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-canceled').then((r) => r.data),
clearFailedDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-failed').then((r) => r.data),
retryFailedDownloads: () =>
api.post<{ retried: number }>('/admin/strm/downloads/retry-failed').then((r) => r.data),
@@ -196,6 +199,9 @@ export const strmAPI = {
clearCanceledUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
clearFailedUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-failed').then((r) => r.data),
retryFailedUploads: () =>
api.post<{ retried: number }>('/admin/strm/uploads/retry-failed').then((r) => r.data),
}
+1 -1
View File
@@ -26,7 +26,7 @@ export function GlobalEvents() {
return
}
if (p.queued) {
toast.loading(String(p.message ?? '云盘扫描已加入后台队列,会自动入库'), { id })
toast.loading(String(p.message ?? '媒体库扫描已加入后台队列,会自动入库'), { id })
return
}
if (p.cloud && p.stage) {
+10 -3
View File
@@ -239,9 +239,16 @@ function LayoutHeaderSearch() {
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-bold text-[var(--app-text)] group-hover:text-brand-500">
{item.title}
</p>
<div className="flex items-center gap-1.5">
<p className="truncate text-xs font-bold text-[var(--app-text)] group-hover:text-brand-500">
{item.title}
</p>
{(item.display_library_name || item.library_name) && (
<span className="shrink-0 text-[9px] px-1.5 py-0.5 rounded bg-[var(--app-panel-elevated)] border border-[var(--app-border)] text-[var(--app-muted)] max-w-[130px] truncate">
{item.display_library_name || item.library_name}
</span>
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-[var(--app-muted)] mt-0.5">
{item.year > 0 && <span>{item.year}</span>}
{item.rating > 0 && (
+1 -1
View File
@@ -43,9 +43,9 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ 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 },
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
]
/** Media browsing links for the mobile drawer when browsing libraries. */
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useState } from 'react'
import {
BookOpen,
Check,
Copy,
Cpu,
Database,
ExternalLink,
Film,
FolderOpen,
Github,
HardDrive,
Info,
Layers,
ListChecks,
MessageSquare,
Play,
Server,
Sparkles,
Tag,
Tv,
Users,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { systemAPI } from '../api/system'
type SystemInfo = {
name: string
version: string
go: string
os: string
arch: string
data_dir: string
cache_dir: string
}
const GITHUB_REPO_URL = 'https://github.com/truewhile/MeBox'
const MODULE_GUIDES = [
{
icon: Film,
title: '媒体库管理',
badge: '核心存储',
description:
'支持创建电影、电视剧、动漫、纪录片等多种类型媒体库。支持本地目录与网络挂载路径,集成文件监控(Inotify / 定时轮询),新增与修改文件自动入库。剧集支持智能按季、集归类与多版本合并。',
},
{
icon: Sparkles,
title: '智能刮削与元数据',
badge: '海报与信息',
description:
'内置 TMDb、Bangumi、TheTVDB、豆瓣等元数据源。入库时自动识别文件名匹配海报、背景图、演职员及中文简介。支持手动精确搜刮、重命名整理规则、识别词过滤(过滤广告词与压制组标签)及 NFO 本地文件导出。',
},
{
icon: Play,
title: '流媒体播放与转码',
badge: '播放引擎',
description:
'现代化 Web 播放器支持原画直连(Direct Play)与 HLS 实时兼容转码。支持 Intel QSV、NVIDIA NVENC、VAAPI 等硬件加速。支持内嵌与外挂字幕(ASS/SRT/VTT)渲染、多音轨切换,并支持一键调用 PotPlayer、VLC、IINA、Infuse 等本地播放器。',
},
{
icon: MessageSquare,
title: '实时弹幕系统',
badge: '弹幕互动',
description:
'播放时自动/手动匹配对应剧集的弹幕源。支持设置弹幕字号、滚动速度、透明度、屏幕占比限制(1/4屏、半屏、全屏)及防遮挡字幕开关,打造沉浸式的观影交流体验。',
},
{
icon: Tv,
title: 'Emby 挂载与远程同步',
badge: '扩展聚合',
description:
'支持以客户端形式直接挂载远程 Emby 媒体服务器,无需本地下载即可无缝串流播放远程资源,同时保留本地的刮削、播放历史、收藏与弹幕能力,实现多端影视资源大聚合。',
},
{
icon: Layers,
title: 'STRM 管理与云盘转存',
badge: '海量存储',
description:
'针对 115、阿里云盘、OpenList 等网盘挂载场景,提供批量的 STRM 索引文件生成与下载队列管理。本地仅占用微量磁盘空间,即可享受数百 TB 云端影视的高速直链秒播。',
},
{
icon: FolderOpen,
title: '内置文件管理器',
badge: '资源维护',
description:
'内置可视化文件浏览器,支持对本地媒体目录及云盘目录进行直接浏览、批量移动、重命名及删除操作,方便快速定位未刮削或命名不规范的文件并进行整理。',
},
{
icon: ListChecks,
title: '后台任务队列',
badge: '异步作业',
description:
'全生命周期监控媒体扫描、元数据批量刮削、STRM 生成及音视频探测等后台异步任务。实时展示队列进度百分比、执行状态及错误日志,支持手动重试与任务取消。',
},
{
icon: Users,
title: '多用户与权限体系',
badge: '安全访问',
description:
'支持管理员与普通用户角色。管理员可精细化控制各账号的访问边界,包括特定媒体库可见性(如敏感/Adult媒体库)、是否允许调用外部播放器、是否允许下载原始文件等。',
},
{
icon: Database,
title: '数据库与系统设置',
badge: '底层基建',
description:
'支持轻量级单文件 SQLite 与高并发 PostgreSQL 数据库,并提供一键平滑数据热迁移工具;支持自定义 HTTPS 证书绑定、转码缓存自动清理及实时系统资源监控。',
},
]
export function AboutSettingsPanel() {
const [info, setInfo] = useState<SystemInfo | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
useEffect(() => {
systemAPI
.info()
.then((res) => {
setInfo({
name: res.name || 'MeBox',
version: res.version || '0.1.0',
go: res.go || '',
os: res.os || '',
arch: res.arch || '',
data_dir: res.data_dir || '',
cache_dir: res.cache_dir || '',
})
})
.catch(() => {
setInfo({
name: 'MeBox',
version: '0.1.0',
go: '',
os: '',
arch: '',
data_dir: '',
cache_dir: '',
})
})
.finally(() => setLoading(false))
}, [])
const handleCopyGitURL = async () => {
try {
await navigator.clipboard.writeText(GITHUB_REPO_URL)
setCopied(true)
toast.success('GitHub 地址已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('复制失败,请手动复制')
}
}
return (
<div className="space-y-6">
{/* 顶部概览与项目卡片 */}
<div className="glass-panel overflow-hidden p-6 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<h2 className="font-display text-2xl sm:text-3xl font-bold text-ink-600">
MeBox 影音管理系统
</h2>
<span className="inline-flex items-center gap-1.5 rounded-full bg-brand-500/10 px-3 py-1 text-xs font-semibold text-brand-600 border border-brand-500/20">
<Tag size={13} />
{loading ? '加载中...' : `v${info?.version || '0.1.0'}`}
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-sand-600">
MeBox 是一款专为个人与家庭设计的现代化开源媒体库管理与流媒体点播系统。
集媒体资源归纳、智能多源刮削、弹幕交互、海量云盘 STRM 转存与多端播放于一体,让观影体验更轻快自由。
</p>
</div>
{/* 仓库链接操作区 */}
<div className="flex flex-wrap items-center gap-3">
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-xl bg-ink-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-ink-700 hover:shadow"
>
<Github size={16} />
<span>访问 GitHub 仓库</span>
<ExternalLink size={14} className="opacity-70" />
</a>
<button
type="button"
onClick={handleCopyGitURL}
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white/80 px-4 py-2.5 text-sm font-medium text-ink-600 shadow-sm transition hover:bg-white hover:text-brand-600"
>
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
<span>{copied ? '已复制' : '复制地址'}</span>
</button>
</div>
</div>
{/* 运行环境规格栏 */}
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4 border-t border-gray-200/60 pt-6">
<div className="rounded-xl bg-sand-100/50 p-3.5">
<div className="flex items-center gap-1.5 text-xs text-sand-500">
<Tag size={13} />
<span>系统版本</span>
</div>
<p className="mt-1 font-mono text-sm font-semibold text-ink-600">
{loading ? '...' : info?.version || '0.1.0'}
</p>
</div>
<div className="rounded-xl bg-sand-100/50 p-3.5">
<div className="flex items-center gap-1.5 text-xs text-sand-500">
<Server size={13} />
<span>运行环境</span>
</div>
<p className="mt-1 font-mono text-sm font-semibold text-ink-600">
{loading ? '...' : `${info?.os || 'linux'} / ${info?.arch || 'amd64'}`}
</p>
</div>
<div className="rounded-xl bg-sand-100/50 p-3.5">
<div className="flex items-center gap-1.5 text-xs text-sand-500">
<Cpu size={13} />
<span>Go 运行时</span>
</div>
<p className="mt-1 font-mono text-sm font-semibold text-ink-600">
{loading ? '...' : info?.go || 'go1.x'}
</p>
</div>
<div className="rounded-xl bg-sand-100/50 p-3.5">
<div className="flex items-center gap-1.5 text-xs text-sand-500">
<HardDrive size={13} />
<span>数据目录</span>
</div>
<p className="mt-1 truncate font-mono text-xs font-semibold text-ink-600" title={info?.data_dir}>
{loading ? '...' : info?.data_dir || './data'}
</p>
</div>
</div>
</div>
{/* 快速入门与使用流程 */}
<div className="glass-panel p-6 sm:p-8 space-y-4">
<div className="flex items-center gap-2">
<BookOpen className="text-brand-500" size={20} />
<h3 className="font-display text-lg font-bold text-ink-600">使用说明与快速上手</h3>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2 rounded-2xl border border-gray-200/80 bg-white/60 p-4 shadow-sm">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-xs font-bold text-white">
1
</span>
<span className="font-semibold text-ink-600 text-sm">创建与关联媒体库</span>
</div>
<p className="text-xs leading-relaxed text-sand-500">
在侧边栏进入「媒体库」,点击添加媒体库。选择媒体类型(电影/电视剧),指定服务器上的实际媒体路径或挂载目录,保存后系统将自动创建索引并监控变动。
</p>
</div>
<div className="flex flex-col gap-2 rounded-2xl border border-gray-200/80 bg-white/60 p-4 shadow-sm">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-xs font-bold text-white">
2
</span>
<span className="font-semibold text-ink-600 text-sm">智能识别与刮削</span>
</div>
<p className="text-xs leading-relaxed text-sand-500">
扫描入库后,后台服务会自动提取文件名并通过 TMDb / 豆瓣 / Bangumi 等抓取高清海报、演职员与分集信息;如遇特殊命名,可在详情页中一键进行「手动搜刮」。
</p>
</div>
<div className="flex flex-col gap-2 rounded-2xl border border-gray-200/80 bg-white/60 p-4 shadow-sm">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-xs font-bold text-white">
3
</span>
<span className="font-semibold text-ink-600 text-sm">多端随心畅享观影</span>
</div>
<p className="text-xs leading-relaxed text-sand-500">
直接在浏览器中享受原画秒开与实时弹幕互动;对于高码率或特殊封装格式,支持开启 HLS 转码播放或直接一键唤醒本地 PotPlayer、Infuse 等外部播放器。
</p>
</div>
</div>
</div>
{/* 各功能模块详细使用说明 */}
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
<Info className="text-brand-500" size={20} />
<h3 className="font-display text-lg font-bold text-ink-600">各功能模块详细说明</h3>
</div>
<span className="text-xs text-sand-500">涵盖系统十大核心子系统</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{MODULE_GUIDES.map((mod) => {
const Icon = mod.icon
return (
<div
key={mod.title}
className="group flex flex-col justify-between rounded-2xl border border-gray-200/90 bg-white/70 p-5 shadow-sm transition hover:border-brand-500/40 hover:bg-white hover:shadow-md"
>
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-500/10 text-brand-500 transition group-hover:bg-brand-500 group-hover:text-white">
<Icon size={18} />
</div>
<h4 className="font-semibold text-ink-600 text-sm sm:text-base">
{mod.title}
</h4>
</div>
<span className="rounded-md bg-sand-100 px-2 py-0.5 text-[11px] font-medium text-sand-600">
{mod.badge}
</span>
</div>
<p className="text-xs leading-relaxed text-sand-600 sm:text-[13px]">
{mod.description}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
+42 -4
View File
@@ -1,12 +1,16 @@
import { FormEvent } from 'react'
import { Plus } from 'lucide-react'
import { Plus, Save } from 'lucide-react'
type AdminUsersFormProps = {
usersCount: number
userLimitLabel: string
maxUsers: number
maxUsersDraft: string
savingLimit: boolean
username: string
password: string
userLimitReached: boolean
onMaxUsersDraftChange: (value: string) => void
onSaveMaxUsers: () => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: (e: FormEvent) => void
@@ -14,27 +18,61 @@ type AdminUsersFormProps = {
export function AdminUsersForm({
usersCount,
userLimitLabel,
maxUsers,
maxUsersDraft,
savingLimit,
username,
password,
userLimitReached,
onMaxUsersDraftChange,
onSaveMaxUsers,
onUsernameChange,
onPasswordChange,
onSubmit,
}: AdminUsersFormProps) {
const draftValue = Number(maxUsersDraft)
const limitChanged = maxUsersDraft.trim() !== '' && Number.isFinite(draftValue) && draftValue !== maxUsers
return (
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<div className="md:col-span-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">用户管理</h2>
<p className="text-xs text-sand-500">
已创建 {usersCount}/{userLimitLabel} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。
已创建 {usersCount}/{maxUsers} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。
</p>
</div>
<span className="rounded-full border border-primary-400/30 px-3 py-1 text-xs text-brand-500">
默认管理员不可删除 · 最高权限
</span>
</div>
<div className="md:col-span-3 flex flex-wrap items-end gap-3 rounded-2xl border border-sand-200/80 bg-white/60 p-4">
<label className="min-w-[10rem] flex-1">
<span className="mb-1 block text-xs font-medium text-sand-500">单实例用户数量上限</span>
<input
type="number"
min={1}
max={10000}
className="input-base"
value={maxUsersDraft}
onChange={(e) => onMaxUsersDraftChange(e.target.value)}
/>
</label>
<button
type="button"
className="neon-button inline-flex items-center justify-center gap-2"
disabled={!limitChanged || savingLimit || !Number.isFinite(draftValue) || draftValue < 1}
onClick={onSaveMaxUsers}
>
<Save size={16} />
{savingLimit ? '保存中…' : '保存上限'}
</button>
<p className="w-full text-xs text-sand-500">
默认 20 人。达到上限后无法继续注册或添加用户;若当前用户数已超过新上限,已有账号不受影响,但需删除部分用户后才能继续添加。
</p>
</div>
<input
required
className="input-base"
+42 -5
View File
@@ -9,8 +9,13 @@ import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog
import { AdminUsersForm } from './AdminUsersForm'
import { AdminUsersTable } from './AdminUsersTable'
const DEFAULT_MAX_USERS = 20
export function AdminUsersPanel() {
const [users, setUsers] = useState<User[]>([])
const [maxUsers, setMaxUsers] = useState(DEFAULT_MAX_USERS)
const [maxUsersDraft, setMaxUsersDraft] = useState(String(DEFAULT_MAX_USERS))
const [savingLimit, setSavingLimit] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [editingID, setEditingID] = useState<string | null>(null)
@@ -19,7 +24,10 @@ export function AdminUsersPanel() {
const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(null)
const refresh = async () => {
setUsers(await adminAPI.listUsers())
const data = await adminAPI.listUsers()
setUsers(data.users)
setMaxUsers(data.max_users)
setMaxUsersDraft(String(data.max_users))
}
useEffect(() => {
refresh().catch(() => undefined)
@@ -27,9 +35,34 @@ export function AdminUsersPanel() {
return () => window.clearInterval(timer)
}, [])
const maxUsers = 20
const userLimitReached = users.length >= maxUsers
const userLimitLabel = String(maxUsers)
const handleSaveMaxUsers = async () => {
const next = Number(maxUsersDraft)
if (!Number.isFinite(next) || next < 1) {
toast.error('用户上限至少为 1')
return
}
if (next > 10000) {
toast.error('用户上限不能超过 10000')
return
}
if (next === maxUsers) return
setSavingLimit(true)
try {
const data = await adminAPI.updateUserLimit(next)
setMaxUsers(data.max_users)
setMaxUsersDraft(String(data.max_users))
toast.success(`用户上限已更新为 ${data.max_users} 人`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'保存用户上限失败'
toast.error(msg)
} finally {
setSavingLimit(false)
}
}
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
@@ -132,10 +165,14 @@ export function AdminUsersPanel() {
<div className="space-y-6">
<AdminUsersForm
usersCount={users.length}
userLimitLabel={userLimitLabel}
maxUsers={maxUsers}
maxUsersDraft={maxUsersDraft}
savingLimit={savingLimit}
username={username}
password={password}
userLimitReached={userLimitReached}
onMaxUsersDraftChange={setMaxUsersDraft}
onSaveMaxUsers={handleSaveMaxUsers}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleCreate}
@@ -173,7 +210,7 @@ function userCreateErrorMessage(err: unknown): string | undefined {
const data = (err as { response?: { data?: { error?: string; max_users?: number } } })?.response?.data
if (!data?.error) return undefined
if (data.error === 'user limit reached' && data.max_users != null) {
return `用户数量已达到授权上限:${data.max_users} 人`
return `用户数量已达到上限:${data.max_users} 人`
}
return data.error
}
+17
View File
@@ -9,6 +9,7 @@ import {
MediaDetailMainContent,
MediaDetailMissing,
} from './MediaDetailPageSections'
import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes'
import { useMediaDetailPageState } from './useMediaDetailPageState'
export function MediaDetailPage() {
@@ -31,6 +32,7 @@ export function MediaDetailPage() {
media={media}
isAdmin={role === 'admin'}
favourite={detail.favourite}
playTargetId={detail.firstPlayableEpisode?.id}
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
onToggleFavourite={detail.toggleFavourite}
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
@@ -42,6 +44,21 @@ export function MediaDetailPage() {
onExportNFO={detail.exportNFO}
onSoftDelete={detail.softDelete}
/>
{detail.hasEpisodes && (
<div className="relative z-10 px-6 pb-8 sm:px-10 space-y-6">
<div className="divider border-gray-200/60" />
<LibrarySeriesEpisodes
loading={detail.loadingEpisodes}
selectedEpisodes={detail.seasonGroups}
selectedSeason={detail.selectedSeason}
visibleEpisodes={detail.visibleEpisodes}
playbackFrom={`/media/${media.id}`}
onSeasonChange={detail.setSelectedSeason}
/>
</div>
)}
<MediaDetailDialogs
media={media}
manualScrapeOpen={detail.manualScrapeOpen}
+4 -6
View File
@@ -1,13 +1,11 @@
import type { Media } from '../types'
import { mediaLibraryTarget } from '../utils/mediaNavigation'
export function mediaLibraryBackTarget(media: Media): string {
const seriesTarget = mediaLibraryTarget(media)
if (seriesTarget) return seriesTarget
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return ''
return `/library/${encodeURIComponent(libraryID)}`
if (libraryID) {
return `/library/${encodeURIComponent(libraryID)}`
}
return '/libraries'
}
export function mediaDetailScrapeMediaType(media: Media): string | undefined {
+13 -4
View File
@@ -18,6 +18,7 @@ interface MediaDetailPlaybackActionsProps {
media: Media
favourite: boolean
onToggleFavourite: () => void
playTargetId?: string
}
interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps {
@@ -93,17 +94,19 @@ export function MediaDetailPlaybackActions({
media,
favourite,
onToggleFavourite,
playTargetId,
}: MediaDetailPlaybackActionsProps) {
const targetId = playTargetId || media.id
return (
<div className="flex flex-wrap gap-3">
<Link to={`/play/${media.id}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
{!isDirectStreamMedia(media) && (
<Link
to={`/play/${media.id}?mode=hls`}
to={`/play/${targetId}?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"
>
@@ -112,7 +115,7 @@ export function MediaDetailPlaybackActions({
</Link>
)}
<ExternalPlayerButton mediaId={media.id} />
<ExternalPlayerButton mediaId={targetId} />
<button
onClick={onToggleFavourite}
@@ -134,6 +137,7 @@ export function MediaDetailMainContent({
media,
isAdmin,
favourite,
playTargetId,
scrapeEpisodeArtwork,
onToggleFavourite,
onScrapeEpisodeArtworkChange,
@@ -153,7 +157,12 @@ export function MediaDetailMainContent({
<MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" />
<div className="flex flex-col gap-5">
<MediaDetailPlaybackActions media={media} favourite={favourite} onToggleFavourite={onToggleFavourite} />
<MediaDetailPlaybackActions
media={media}
favourite={favourite}
onToggleFavourite={onToggleFavourite}
playTargetId={playTargetId}
/>
{isAdmin && (
<MediaDetailAdminPanel
media={media}
+194 -2
View File
@@ -227,8 +227,13 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
}, [tasks, filter, search])
const counts = snapshot?.counts
const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
const pendingCount = counts?.pending ?? 0
const runningCount = counts?.running ?? 0
const activeTaskCount = pendingCount + runningCount
const doneCount = counts?.done ?? 0
const failedCount = counts?.failed ?? 0
const canceledCount = counts?.canceled ?? 0
const finishedCount = doneCount + failedCount + canceledCount
const allCurrentChecked =
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
@@ -450,7 +455,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
)}
</div>
{selectedIds.size > 0 && (
{selectedIds.size > 0 ? (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
@@ -490,6 +495,193 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
<X size={13} />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{/* 当前状态专属快捷批量按钮 */}
{filter === 'all' && (
<>
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() => runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试 ({failedCount})
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() => runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消 ({activeTaskCount})
</button>
)}
{finishedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() => runGlobalBatch(() => scraperAPI.clearFinished(), '确定清空所有已完成、失败及取消的历史记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除 ({finishedCount})
</button>
)}
</>
)}
{(filter === 'pending' || filter === 'running') && (
<button
type="button"
disabled={batchBusy || activeTaskCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消{activeTaskCount > 0 ? ` (${activeTaskCount})` : ''}
</button>
)}
{filter === 'done' && (
<button
type="button"
disabled={batchBusy || doneCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.clearDone(), '确定清空所有已匹配完成的记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{doneCount > 0 ? ` (${doneCount})` : ''}
</button>
)}
{filter === 'failed' && (
<>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.clearFailed(), '确定清空所有失败记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
</>
)}
{filter === 'canceled' && (
<button
type="button"
disabled={batchBusy || canceledCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.clearCanceled(), '确定清空所有已取消的任务记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{canceledCount > 0 ? ` (${canceledCount})` : ''}
</button>
)}
{/* 下拉批量操作菜单:随时可做任意全局操作 */}
<details className="relative inline-block">
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 [&::-webkit-details-marker]:hidden">
<Trash2 size={12} className="text-sand-500" />
<span>批量清理</span>
</summary>
<div className="absolute right-0 top-9 z-30 min-w-44 rounded-xl border border-gray-200 bg-white p-1.5 shadow-xl backdrop-blur">
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-brand-500 hover:bg-brand-50"
>
<RefreshCw size={13} />
<span>重试所有失败 ({failedCount})</span>
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-amber-600 hover:bg-amber-50"
>
<Ban size={13} />
<span>取消所有进行中 ({activeTaskCount})</span>
</button>
)}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearDone(), '确定清空所有已匹配完成的记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录 ({doneCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearFailed(), '确定清空所有失败的记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<AlertCircle size={13} />
<span>清空失败记录 ({failedCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearCanceled(), '确定清空所有已取消的任务记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<Ban size={13} className="text-amber-500" />
<span>清空已取消记录 ({canceledCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearFinished(), '确定清空所有已完成、失败及取消的历史记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录 ({finishedCount})</span>
</button>
</div>
</details>
</div>
)}
</div>
+2
View File
@@ -8,6 +8,7 @@ import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { FFToolsPanel } from '../components/FFToolsPanel'
import { AboutSettingsPanel } from './AboutSettingsPanel'
import { AdultSettingsPanel } from './AdultSettingsPanel'
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
@@ -174,6 +175,7 @@ export function SettingsPage() {
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.key === 'adult' && <AdultSettingsPanel />}
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
{group.key === 'about' && <AboutSettingsPanel />}
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
+253 -3
View File
@@ -224,8 +224,13 @@ export function StrmQueuePanel({
}, [tasks, filter, search])
const counts = snapshot?.counts
const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
const pendingCount = counts?.pending ?? 0
const runningCount = counts?.running ?? 0
const activeTaskCount = pendingCount + runningCount
const doneCount = counts?.done ?? 0
const failedCount = counts?.failed ?? 0
const canceledCount = counts?.canceled ?? 0
const finishedCount = doneCount + failedCount + canceledCount
const allCurrentChecked =
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
@@ -475,8 +480,8 @@ export function StrmQueuePanel({
)}
</div>
{/* Selected Batch Toolbar */}
{selectedIds.size > 0 && (
{/* Batch Actions Toolbar */}
{selectedIds.size > 0 ? (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
@@ -516,6 +521,251 @@ export function StrmQueuePanel({
<X size={13} />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{/* 当前状态专属快捷批量按钮 */}
{filter === 'all' && (
<>
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.retryFailedDownloads() : strmAPI.retryFailedUploads()),
'确定重新入队所有失败任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试 ({failedCount})
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.cancelPendingDownloads() : strmAPI.cancelPendingUploads()),
'确定取消所有排队及进行中的任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消 ({activeTaskCount})
</button>
)}
{finishedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearFinishedDownloads() : strmAPI.clearFinishedUploads()),
'确定清空所有已完成、失败及取消的历史记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除 ({finishedCount})
</button>
)}
</>
)}
{(filter === 'pending' || filter === 'running') && (
<button
type="button"
disabled={batchBusy || activeTaskCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.cancelPendingDownloads() : strmAPI.cancelPendingUploads()),
'确定取消所有排队及进行中的任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消{activeTaskCount > 0 ? ` (${activeTaskCount})` : ''}
</button>
)}
{filter === 'done' && (
<button
type="button"
disabled={batchBusy || doneCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearDoneDownloads() : strmAPI.clearDoneUploads()),
'确定清空所有已完成记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{doneCount > 0 ? ` (${doneCount})` : ''}
</button>
)}
{filter === 'failed' && (
<>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.retryFailedDownloads() : strmAPI.retryFailedUploads()),
'确定重新入队所有失败任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearFailedDownloads() : strmAPI.clearFailedUploads()),
'确定清空所有失败记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
</>
)}
{filter === 'canceled' && (
<button
type="button"
disabled={batchBusy || canceledCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearCanceledDownloads() : strmAPI.clearCanceledUploads()),
'确定清空所有已取消的任务记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{canceledCount > 0 ? ` (${canceledCount})` : ''}
</button>
)}
{/* 下拉批量操作菜单:随时可做任意全局操作 */}
<details className="relative inline-block">
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 [&::-webkit-details-marker]:hidden">
<Trash2 size={12} className="text-sand-500" />
<span>批量清理</span>
</summary>
<div className="absolute right-0 top-9 z-30 min-w-44 rounded-xl border border-gray-200 bg-white p-1.5 shadow-xl backdrop-blur">
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.retryFailedDownloads() : strmAPI.retryFailedUploads()),
'确定重新入队所有失败任务?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-brand-500 hover:bg-brand-50"
>
<RefreshCw size={13} />
<span>重试所有失败 ({failedCount})</span>
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.cancelPendingDownloads() : strmAPI.cancelPendingUploads()),
'确定取消所有排队及进行中的任务?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-amber-600 hover:bg-amber-50"
>
<Ban size={13} />
<span>取消所有进行中 ({activeTaskCount})</span>
</button>
)}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearDoneDownloads() : strmAPI.clearDoneUploads()),
'确定清空所有已完成记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录 ({doneCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearFailedDownloads() : strmAPI.clearFailedUploads()),
'确定清空所有失败记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<AlertCircle size={13} />
<span>清空失败记录 ({failedCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearCanceledDownloads() : strmAPI.clearCanceledUploads()),
'确定清空所有已取消的任务记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<Ban size={13} className="text-amber-500" />
<span>清空已取消记录 ({canceledCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearFinishedDownloads() : strmAPI.clearFinishedUploads()),
'确定清空所有已完成、失败及取消的历史记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录 ({finishedCount})</span>
</button>
</div>
</details>
</div>
)}
</div>
+8
View File
@@ -14,6 +14,13 @@ export const databaseSettingsGroup: SettingGroup = {
items: [],
}
export const aboutSettingsGroup: SettingGroup = {
key: 'about',
label: '关于',
description: '项目信息、版本信息与使用指南',
items: [],
}
export const GROUPS: SettingGroup[] = [
generalSettingsGroup,
databaseSettingsGroup,
@@ -21,6 +28,7 @@ export const GROUPS: SettingGroup[] = [
recognitionWordsSettingsGroup,
danmakuSettingsGroup,
adultSettingsGroup,
aboutSettingsGroup,
]
export const ALL_KEYS = new Set(GROUPS.flatMap((group) => group.items.map((item) => item.key)))
+2 -2
View File
@@ -163,8 +163,8 @@ function useEditableLibraryRootActions(refresh: () => Promise<void>, drafts: Edi
function useLibraryActions(refresh: () => Promise<void>) {
const scanLibrary = async (library: Library) => {
const result = await libraryAPI.scan(library.id)
if (result.queued) toast.success('云盘扫描已加入后台队列,会自动入库')
else toast.success(`扫描完成,新增 ${result.added},更新 ${result.updated ?? 0}`)
if (result.queued) toast.success(result.message || '媒体库扫描已加入后台队列,会自动入库')
else toast.success(`扫描完成,新增 ${result.added ?? 0},更新 ${result.updated ?? 0}`)
}
const toggleCarouselLibrary = async (library: Library) => {
+16 -3
View File
@@ -40,6 +40,13 @@ export function useLibraryScanStatus({
setScanProgress(String(event.message ?? '扫描已排队,后台会自动入库'))
return
}
if (typeof event.visited === 'number' && !event.finished) {
setScanning(true)
const added = Number(event.added ?? 0)
const updated = Number(event.updated ?? 0)
setScanProgress(`正在扫描:已扫描 ${event.visited} 项 · 新增 ${added} · 更新 ${updated}`)
return
}
}, [isAdmin, libraryID, onLibraryChanged])
useWebSocket(onRealtimeEvent)
@@ -49,13 +56,19 @@ export function useLibraryScanStatus({
setScanProgress('正在提交扫描任务…')
try {
const result = await libraryAPI.scan(libraryID)
toast.success(`扫描完成:新增 ${result.added} 项,更新 ${result.updated ?? 0} 项`)
setScanProgress(`扫描完成:新增 ${result.added} · 更新 ${result.updated ?? 0}`)
if (result.queued) {
const msg = result.message || '扫描任务已在后台启动,正在扫描…'
toast.success(msg)
setScanProgress(msg)
return
}
toast.success(`扫描完成:新增 ${result.added ?? 0} 项,更新 ${result.updated ?? 0} 项`)
setScanProgress(`扫描完成:新增 ${result.added ?? 0} · 更新 ${result.updated ?? 0}`)
setScanning(false)
onLibraryChanged()
} catch {
toast.error('扫描失败')
setScanProgress('扫描失败,请查看日志或稍后重试')
} finally {
setScanning(false)
}
}, [libraryID, onLibraryChanged])
+76 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import toast from 'react-hot-toast'
@@ -20,6 +20,8 @@ interface MediaDetailRefreshParams {
setMedia: Dispatch<SetStateAction<Media | null>>
setFavourite: Dispatch<SetStateAction<boolean>>
setLoading: Dispatch<SetStateAction<boolean>>
setEpisodes: Dispatch<SetStateAction<Media[]>>
setLoadingEpisodes: Dispatch<SetStateAction<boolean>>
}
interface MediaDetailActionsParams {
@@ -34,12 +36,23 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
const [media, setMedia] = useState<Media | null>(null)
const [favourite, setFavourite] = useState(false)
const [loading, setLoading] = useState(true)
const [episodes, setEpisodes] = useState<Media[]>([])
const [loadingEpisodes, setLoadingEpisodes] = useState(false)
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
const [organizeOpen, setOrganizeOpen] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
const refresh = useMediaDetailRefresh({ id, setMedia, setFavourite, setLoading })
const refresh = useMediaDetailRefresh({
id,
setMedia,
setFavourite,
setLoading,
setEpisodes,
setLoadingEpisodes,
})
const actions = useMediaDetailActions({
media,
scrapeEpisodeArtwork,
@@ -52,6 +65,41 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
refresh().catch(() => undefined)
}, [refresh])
const seasonGroups = useMemo(() => {
if (episodes.length === 0) return []
const seasons = new Map<number, Media[]>()
for (const ep of episodes) {
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
if (!seasons.has(s)) seasons.set(s, [])
seasons.get(s)!.push(ep)
}
for (const [, list] of seasons) {
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
}
return Array.from(seasons.entries())
.sort(([a], [b]) => a - b)
.map(([season, list]) => ({ season, episodes: list }))
}, [episodes])
const visibleEpisodes = useMemo(() => {
if (selectedSeason == null) return seasonGroups[0]?.episodes ?? []
return seasonGroups.find((s) => s.season === selectedSeason)?.episodes ?? []
}, [seasonGroups, selectedSeason])
const hasEpisodes = useMemo(() => {
if (episodes.length > 1) return true
if (episodes.length === 1 && media) {
// 只有单条时,如果明确是集(有 episode_num > 0)或 ID 与自身不同,则按剧集渲染
return episodes[0].episode_num > 0 || episodes[0].id !== media.id
}
return false
}, [episodes, media])
const firstPlayableEpisode = useMemo(() => {
if (episodes.length === 0) return null
return (visibleEpisodes.length > 0 ? visibleEpisodes[0] : episodes[0]) || null
}, [episodes, visibleEpisodes])
const handleMetadataSaved = useCallback(async (next: Media) => {
setMedia(next)
await refresh()
@@ -61,6 +109,14 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
media,
favourite,
loading,
episodes,
loadingEpisodes,
seasonGroups,
selectedSeason,
visibleEpisodes,
hasEpisodes,
firstPlayableEpisode,
setSelectedSeason,
manualScrapeOpen,
metadataEditOpen,
organizeOpen,
@@ -80,19 +136,31 @@ function useMediaDetailRefresh({
setMedia,
setFavourite,
setLoading,
setEpisodes,
setLoadingEpisodes,
}: MediaDetailRefreshParams): () => Promise<void> {
return useCallback(async () => {
if (!id) return
setLoading(true)
setLoadingEpisodes(true)
try {
const nextMedia = await mediaAPI.get(id)
setMedia(nextMedia)
const favourites = await playbackAPI.listFavourites().catch(() => [])
setFavourite(favourites.some((item) => item.id === nextMedia.id))
// 异步加载分集列表
try {
const epRes = await mediaAPI.getEpisodes(id)
setEpisodes(epRes.items ?? [])
} catch {
setEpisodes([])
} finally {
setLoadingEpisodes(false)
}
} finally {
setLoading(false)
}
}, [id, setFavourite, setLoading, setMedia])
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
}
function useMediaDetailActions({
@@ -121,10 +189,12 @@ function useMediaDetailActions({
}
function goBackFromMediaDetail(media: Media | null, navigate: NavigateFunction, replace = false): void {
if (!media) return
if (!media) {
navigate('/libraries')
return
}
const backTarget = mediaLibraryBackTarget(media)
if (backTarget) navigate(backTarget, replace ? { replace: true } : undefined)
else navigate(-1)
navigate(backTarget || '/libraries', replace ? { replace: true } : undefined)
}
async function toggleMediaFavourite(