mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
Remove unused recycle bin and add optional local file delete (#26)
Soft-delete restore/purge/list and the recycle_purge job are gone. Admin delete now removes library index rows, with an optional delete_files query that also deletes local media + sidecar NFO. UI copy is renamed from 移入回收站 to 删除, with a confirm checkbox. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
// Package handler — media delete endpoint.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func deleteMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
deleteFiles := parseDeleteFilesQuery(c.Query("delete_files"))
|
||||
if err := svc.Media.DeleteMedia(c.Request.Context(), c.Param("id"), deleteFiles); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func parseDeleteFilesQuery(raw string) bool {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if raw == "1" || raw == "true" || raw == "yes" {
|
||||
return true
|
||||
}
|
||||
v, err := strconv.ParseBool(raw)
|
||||
return err == nil && v
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDeleteFilesQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"0", false},
|
||||
{"false", false},
|
||||
{"1", true},
|
||||
{"true", true},
|
||||
{"TRUE", true},
|
||||
{"yes", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := parseDeleteFilesQuery(tc.raw); got != tc.want {
|
||||
t.Fatalf("parseDeleteFilesQuery(%q)=%v, want %v", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Package handler — recycle bin endpoints.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func deleteMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Media.SoftDelete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Media.RestoreDeleted(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Media.PurgeDeleted(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
||||
registerAuthedCollectionRoutes(authed, svc)
|
||||
registerAuthedFileRoutes(authed, svc)
|
||||
registerAuthedDLNARoutes(authed, svc)
|
||||
registerAuthedRecycleAndRealtimeRoutes(authed, svc)
|
||||
registerAuthedRealtimeRoutes(authed, svc)
|
||||
registerAuthedUISurfaceRoutes(authed, svc)
|
||||
registerAuthedSearchRoutes(authed, svc)
|
||||
registerAuthedSystemExtraRoutes(authed, svc)
|
||||
|
||||
@@ -50,8 +50,6 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
|
||||
authed.POST("/media/scrape/apply", middleware.AdminRequired(), manualScrapeApplyBatchHandler(svc))
|
||||
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
|
||||
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
|
||||
authed.POST("/media/:id/restore", middleware.AdminRequired(), restoreMediaHandler(svc))
|
||||
authed.DELETE("/media/:id/purge", middleware.AdminRequired(), purgeMediaHandler(svc))
|
||||
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
|
||||
|
||||
@@ -20,7 +20,7 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.POST("/dlna/cast", dlnaCastHandler(svc))
|
||||
}
|
||||
|
||||
func registerAuthedRecycleAndRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
func registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
}
|
||||
|
||||
@@ -240,8 +240,8 @@ func (r *MediaRepository) DeleteByLibraryRoot(ctx context.Context, libraryID, ro
|
||||
Delete(&model.Media{}).Error
|
||||
}
|
||||
|
||||
// PurgeByLibrary permanently removes media tied to a library. Used for virtual
|
||||
// cloud mounts where "remove mount" must not populate the recycle bin.
|
||||
// PurgeByLibrary permanently removes media tied to a library. Used when
|
||||
// removing a library or virtual mount so indexed rows are dropped immediately.
|
||||
func (r *MediaRepository) PurgeByLibrary(ctx context.Context, libraryID string) error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// DeleteMedia removes a media row from the library index.
|
||||
// When deleteFiles is true and the path is a local filesystem file, the media
|
||||
// file and its sidecar NFO are also removed. cloud:// paths are never touched on disk.
|
||||
func (s *MediaService) DeleteMedia(ctx context.Context, id string, deleteFiles bool) error {
|
||||
var media model.Media
|
||||
err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&media).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if deleteFiles {
|
||||
if err := deleteLocalMediaFiles(s.log, media.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
|
||||
if err == nil {
|
||||
s.invalidateMediaCache(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteLocalMediaFiles(log *zap.Logger, path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(path), "cloud://") {
|
||||
if log != nil {
|
||||
log.Info("skip local file delete for cloud media path", zap.String("path", path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := removeMediaAndNFO(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 TestDeleteMediaInvalidatesMediaAndStatsCache(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: "local-media"},
|
||||
Title: "Cached Movie",
|
||||
Path: filepath.Join(t.TempDir(), "Cached Movie.mkv"),
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
|
||||
cache.SetJSON(t.Context(), "media:list:test", map[string]string{"state": "stale"}, time.Minute)
|
||||
cache.SetJSON(t.Context(), "stats:snapshot:base", map[string]int{"media": 1}, time.Minute)
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
|
||||
if err := svc.DeleteMedia(t.Context(), media.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mediaCache map[string]string
|
||||
if cache.GetJSON(t.Context(), "media:list:test", &mediaCache) {
|
||||
t.Fatal("delete should invalidate media cache")
|
||||
}
|
||||
var statsCache map[string]int
|
||||
if cache.GetJSON(t.Context(), "stats:snapshot:base", &statsCache) {
|
||||
t.Fatal("delete should invalidate stats cache")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("media row still present, count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMediaRemovesLocalFilesWhenRequested(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "Movie.mkv")
|
||||
nfo := filepath.Join(dir, "Movie.nfo")
|
||||
if err := os.WriteFile(mediaPath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfo, []byte("nfo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: "local-delete-files"},
|
||||
Title: "Movie",
|
||||
Path: mediaPath,
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
if err := svc.DeleteMedia(t.Context(), media.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(mediaPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected media file removed, err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(nfo); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected nfo removed, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMediaKeepsLocalFilesByDefault(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "Keep.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: "keep-files"},
|
||||
Title: "Keep",
|
||||
Path: mediaPath,
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
if err := svc.DeleteMedia(t.Context(), media.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(mediaPath); err != nil {
|
||||
t.Fatalf("expected media file kept, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMediaSkipsCloudPaths(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: "cloud-media"},
|
||||
Title: "Cloud",
|
||||
Path: "cloud://openlist/Movie.mkv",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
if err := svc.DeleteMedia(t.Context(), media.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("cloud media row still present, count=%d", count)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
const maxRecycleBinRecords = 200
|
||||
|
||||
// SoftDelete 物理删除媒体记录(统一硬删除以降低 SQLite 存储与索引压力)。
|
||||
func (s *MediaService) SoftDelete(ctx context.Context, id string) error {
|
||||
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
|
||||
if err == nil {
|
||||
s.invalidateMediaCache(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RestoreDeleted unsets DeletedAt for a single media row.
|
||||
func (s *MediaService) RestoreDeleted(ctx context.Context, id string) error {
|
||||
err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("id = ?", id).Update("deleted_at", nil).Error
|
||||
if err == nil {
|
||||
s.invalidateMediaCache(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListRecycleBin returns every soft-deleted row, newest first.
|
||||
func (s *MediaService) ListRecycleBin(ctx context.Context, limit int) ([]model.Media, error) {
|
||||
if err := pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > maxRecycleBinRecords {
|
||||
limit = maxRecycleBinRecords
|
||||
}
|
||||
var rows []model.Media
|
||||
err := s.repo.DB.Unscoped().
|
||||
Where("deleted_at IS NOT NULL").
|
||||
Order("deleted_at desc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func pruneRecycleBinRows(ctx context.Context, db *gorm.DB, keep int) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if keep <= 0 {
|
||||
keep = maxRecycleBinRecords
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
}
|
||||
if err := db.WithContext(ctx).Unscoped().
|
||||
Model(&model.Media{}).
|
||||
Select("id").
|
||||
Where("deleted_at IS NOT NULL").
|
||||
Order("deleted_at desc").
|
||||
Limit(100000).
|
||||
Offset(keep).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ID != "" {
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.Media{}).Error
|
||||
}
|
||||
|
||||
// PurgeDeleted permanently removes a soft-deleted row from the database.
|
||||
func (s *MediaService) PurgeDeleted(ctx context.Context, id string) error {
|
||||
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
|
||||
if err == nil {
|
||||
s.invalidateMediaCache(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
now := time.Now()
|
||||
for i := 0; i < maxRecycleBinRecords+5; i++ {
|
||||
deletedAt := now.Add(time.Duration(i) * time.Second)
|
||||
media := model.Media{
|
||||
Base: model.Base{
|
||||
ID: fmt.Sprintf("media-%03d", i),
|
||||
DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true},
|
||||
},
|
||||
Title: fmt.Sprintf("Movie %03d", i),
|
||||
Path: filepath.Join(t.TempDir(), fmt.Sprintf("Movie %03d.mkv", i)),
|
||||
}
|
||||
if err := db.Unscoped().Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
rows, err := svc.ListRecycleBin(t.Context(), 500)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != maxRecycleBinRecords {
|
||||
t.Fatalf("recycle rows = %d, want %d", len(rows), maxRecycleBinRecords)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Unscoped().Model(&model.Media{}).Where("deleted_at IS NOT NULL").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != maxRecycleBinRecords {
|
||||
t.Fatalf("stored recycle rows = %d, want %d", count, maxRecycleBinRecords)
|
||||
}
|
||||
var oldCount int64
|
||||
if err := db.Unscoped().Model(&model.Media{}).Where("id IN ?", []string{"media-000", "media-001", "media-002", "media-003", "media-004"}).Count(&oldCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oldCount != 0 {
|
||||
t.Fatalf("oldest recycle rows were not pruned, count=%d", oldCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDeleteInvalidatesMediaAndStatsCache(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: "local-media"},
|
||||
Title: "Cached Movie",
|
||||
Path: filepath.Join(t.TempDir(), "Cached Movie.mkv"),
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
|
||||
cache.SetJSON(t.Context(), "media:list:test", map[string]string{"state": "stale"}, time.Minute)
|
||||
cache.SetJSON(t.Context(), "stats:snapshot:base", map[string]int{"media": 1}, time.Minute)
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
|
||||
if err := svc.SoftDelete(t.Context(), media.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mediaCache map[string]string
|
||||
if cache.GetJSON(t.Context(), "media:list:test", &mediaCache) {
|
||||
t.Fatal("soft delete should invalidate media cache")
|
||||
}
|
||||
var statsCache map[string]int
|
||||
if cache.GetJSON(t.Context(), "stats:snapshot:base", &statsCache) {
|
||||
t.Fatal("soft delete should invalidate stats cache")
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,6 @@
|
||||
// organize_source opt-in — organize the configured staging folder.
|
||||
// transcode_cleanup every 24 h — purge HLS transcode artefacts
|
||||
// older than 24 h.
|
||||
// recycle_purge every 24 h — empty the recycle bin of rows
|
||||
// soft-deleted more than 30 days
|
||||
// ago.
|
||||
//
|
||||
// Each job runs at most once at a time (an in-flight run blocks the
|
||||
// next tick). All work happens on a long-lived background context so
|
||||
@@ -128,11 +125,6 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
interval: 24 * time.Hour,
|
||||
run: s.jobCleanTranscodeCache,
|
||||
},
|
||||
{
|
||||
name: "recycle_purge",
|
||||
interval: 24 * time.Hour,
|
||||
run: s.jobPurgeRecycleBin,
|
||||
},
|
||||
{
|
||||
name: "image_cache_cleanup",
|
||||
interval: 1 * time.Hour,
|
||||
|
||||
@@ -8,9 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// jobScanLibraries re-walks every enabled library.
|
||||
@@ -181,29 +178,6 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||
return walkAndPrune(s.cacheDir+"/hls", cutoff)
|
||||
}
|
||||
|
||||
// jobPurgeRecycleBin permanently deletes media rows soft-deleted >30 days
|
||||
// ago. The on-disk file is left untouched (delete is operator-driven).
|
||||
func (s *SchedulerService) jobPurgeRecycleBin(ctx context.Context) error {
|
||||
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
||||
res := s.repo.DB.WithContext(ctx).
|
||||
Unscoped().
|
||||
Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil && !isMissingTableErr(res.Error) {
|
||||
return res.Error
|
||||
}
|
||||
return pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords)
|
||||
}
|
||||
|
||||
// isMissingTableErr lets the test harness ignore "no such table" errors
|
||||
// that show up before AutoMigrate has run.
|
||||
func isMissingTableErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return err == gorm.ErrInvalidDB
|
||||
}
|
||||
|
||||
// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit.
|
||||
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
if s.cacheDir == "" {
|
||||
|
||||
Reference in New Issue
Block a user