This commit is contained in:
truewhile
2026-08-27 21:24:01 +08:00
parent 22b7290ee1
commit 496a897782
21 changed files with 1333 additions and 606 deletions
+33 -4
View File
@@ -147,7 +147,9 @@ roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
type updateLibraryReq struct {
CoverURL string `json:"cover_url"`
CoverURL *string `json:"cover_url"`
SortOrder *int `json:"sort_order"`
CarouselEnabled *bool `json:"carousel_enabled"`
}
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
@@ -157,9 +159,17 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
if req.CoverURL != nil {
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), *req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if req.SortOrder != nil || req.CarouselEnabled != nil {
if err := svc.Media.UpdateLibraryFields(c.Request.Context(), c.Param("id"), req.SortOrder, req.CarouselEnabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || lib == nil {
@@ -170,6 +180,25 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
type reorderLibrariesReq struct {
IDs []string `json:"ids" binding:"required"`
}
func reorderLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.ReorderLibraries(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": len(req.IDs)})
}
}
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
+4
View File
@@ -50,6 +50,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/strm/downloads", downloadQueueHandler(svc))
admin.POST("/strm/downloads/:id/cancel", cancelStrmDownloadHandler(svc))
admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc))
admin.DELETE("/strm/downloads/:id", deleteStrmDownloadHandler(svc))
admin.POST("/strm/downloads/batch", batchActionDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
@@ -58,6 +60,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/strm/uploads", uploadQueueHandler(svc))
admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc))
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc))
admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
}
@@ -21,6 +21,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.GET("/libraries/:id", getLibraryHandler(svc))
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
+57
View File
@@ -393,6 +393,63 @@ func retryStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
}
}
func deleteStrmDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteDownloadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func deleteStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteUploadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
type queueBatchReq struct {
Action string `json:"action" binding:"required"`
IDs []string `json:"ids" binding:"required"`
}
func batchActionDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Strm.BatchActionDownloadTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
func batchActionUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Strm.BatchActionUploadTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
// ─── 下载队列批量操作 ─────────────────────────────────────────────────────────
func clearDoneDownloadsHandler(svc *service.Container) gin.HandlerFunc {
+8 -6
View File
@@ -3,12 +3,14 @@ package model
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct {
Base
Name string `gorm:"size:128;not null" json:"name"`
Path string `gorm:"size:1024;not null" json:"path"`
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
Name string `gorm:"size:128;not null" json:"name"`
Path string `gorm:"size:1024;not null" json:"path"`
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
CarouselEnabled bool `gorm:"default:true" json:"carousel_enabled"` // 是否参与首页海报轮播
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
}
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
+18 -1
View File
@@ -50,7 +50,7 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
// List returns all enabled+disabled libraries.
func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
var ls []model.Library
q := r.db.WithContext(ctx).Order("created_at asc")
q := r.db.WithContext(ctx).Order("sort_order asc, created_at asc")
if r.hasLibraryRootsTable() {
q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order asc, created_at asc")
@@ -60,6 +60,23 @@ func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
return ls, err
}
// SetSortOrder assigns sort_order to libraries, preserving position order for
// any library not present in the provided map.
func (r *LibraryRepository) SetSortOrder(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.Library{}).Where("id = ?", id).
Update("sort_order", i).Error; err != nil {
return err
}
}
return nil
})
}
// FindByID returns the library, or (nil, nil) when missing.
func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) {
var l model.Library
+120
View File
@@ -308,6 +308,66 @@ func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) erro
})
}
// DeleteBatch 批量删除指定 ID 的下载任务。
func (r *StrmDownloadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryBatch 批量重试指定 ID 的失败/已取消下载任务。
func (r *StrmDownloadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelBatch 批量取消指定 ID 的排队/进行中下载任务。
func (r *StrmDownloadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearDone 清空全部已完成下载任务。
func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
var count int64
@@ -555,6 +615,66 @@ func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error
})
}
// DeleteBatch 批量删除指定 ID 的上传任务。
func (r *StrmUploadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryBatch 批量重试指定 ID 的失败/已取消上传任务。
func (r *StrmUploadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelBatch 批量取消指定 ID 的排队/进行中上传任务。
func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearCanceled 清空全部已取消上传任务。
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
+21
View File
@@ -105,6 +105,27 @@ func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverU
Update("cover_url", strings.TrimSpace(coverURL)).Error
}
// UpdateLibraryFields updates sort_order / carousel_enabled on a library.
func (s *MediaService) UpdateLibraryFields(ctx context.Context, libraryID string, sortOrder *int, carouselEnabled *bool) error {
updates := map[string]any{}
if sortOrder != nil {
updates["sort_order"] = *sortOrder
}
if carouselEnabled != nil {
updates["carousel_enabled"] = *carouselEnabled
}
if len(updates) == 0 || strings.TrimSpace(libraryID) == "" {
return nil
}
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).
Where("id = ?", libraryID).Updates(updates).Error
}
// ReorderLibraries persists a full media-library ordering.
func (s *MediaService) ReorderLibraries(ctx context.Context, ids []string) error {
return s.repo.Library.SetSortOrder(ctx, ids)
}
func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) {
if s == nil || s.repo == nil || s.repo.Library == nil {
return nil, nil
+38
View File
@@ -551,6 +551,44 @@ func (s *StrmService) RetryUploadTask(ctx context.Context, id string) error {
// ─── 下载队列批量操作(handler 使用) ─────────────────────────────────────────
// DeleteDownloadTask 删除一个下载任务记录。
func (s *StrmService) DeleteDownloadTask(ctx context.Context, id string) error {
return s.repo.StrmDownload.Delete(ctx, id)
}
// DeleteUploadTask 删除一个上传任务记录。
func (s *StrmService) DeleteUploadTask(ctx context.Context, id string) error {
return s.repo.StrmUpload.Delete(ctx, id)
}
// BatchActionDownloadTasks 对选中的下载任务执行批量操作(delete / retry / cancel)。
func (s *StrmService) BatchActionDownloadTasks(ctx context.Context, action string, ids []string) (int64, error) {
switch action {
case "delete":
return s.repo.StrmDownload.DeleteBatch(ctx, ids)
case "retry":
return s.repo.StrmDownload.RetryBatch(ctx, ids)
case "cancel":
return s.repo.StrmDownload.CancelBatch(ctx, ids)
default:
return 0, fmt.Errorf("不支持的批量操作: %s", action)
}
}
// BatchActionUploadTasks 对选中的上传任务执行批量操作(delete / retry / cancel)。
func (s *StrmService) BatchActionUploadTasks(ctx context.Context, action string, ids []string) (int64, error) {
switch action {
case "delete":
return s.repo.StrmUpload.DeleteBatch(ctx, ids)
case "retry":
return s.repo.StrmUpload.RetryBatch(ctx, ids)
case "cancel":
return s.repo.StrmUpload.CancelBatch(ctx, ids)
default:
return 0, fmt.Errorf("不支持的批量操作: %s", action)
}
}
// ClearDoneDownloadTasks 清空全部已完成下载记录,返回删除数量。
func (s *StrmService) ClearDoneDownloadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmDownload.ClearDone(ctx)