diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 2942b2f..fa6c6b0 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -50,12 +50,14 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(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)) admin.POST("/strm/downloads/retry-failed", retryAllFailedDownloadsHandler(svc)) admin.POST("/strm/downloads/cancel-pending", cancelPendingDownloadsHandler(svc)) admin.GET("/strm/uploads", uploadQueueHandler(svc)) admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc)) admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc)) admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc)) + admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc)) } func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 50ffb64..a786f6f 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -392,6 +392,28 @@ func clearFinishedDownloadsHandler(svc *service.Container) gin.HandlerFunc { } } +func clearCanceledDownloadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + n, err := svc.Strm.ClearCanceledDownloadTasks(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()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": n}) + } +} + func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context()) diff --git a/internal/handler/strm_routes_test.go b/internal/handler/strm_routes_test.go index 6220be1..ff7eed0 100644 --- a/internal/handler/strm_routes_test.go +++ b/internal/handler/strm_routes_test.go @@ -50,10 +50,16 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) { "GET /api/admin/strm/downloads", "POST /api/admin/strm/downloads/:id/cancel", "POST /api/admin/strm/downloads/:id/retry", - "GET /api/admin/strm/uploads", - "POST /api/admin/strm/uploads/:id/cancel", - "POST /api/admin/strm/uploads/:id/retry", - "GET /api/strm/play/:provider/:file", + "POST /api/admin/strm/downloads/clear-finished", + "POST /api/admin/strm/downloads/clear-canceled", + "POST /api/admin/strm/downloads/retry-failed", + "POST /api/admin/strm/downloads/cancel-pending", + "GET /api/admin/strm/uploads", + "POST /api/admin/strm/uploads/:id/cancel", + "POST /api/admin/strm/uploads/:id/retry", + "POST /api/admin/strm/uploads/cancel-pending", + "POST /api/admin/strm/uploads/clear-canceled", + "GET /api/strm/play/:provider/:file", } { if !routes[want] { t.Fatalf("%s route is not registered", want) diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go index 41ea1c7..a5e3e08 100644 --- a/internal/handler/watch_history.go +++ b/internal/handler/watch_history.go @@ -163,7 +163,7 @@ func historyDeleteHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"}) return } - res := q.Delete(&model.PlaybackHistory{}) + res := q.Unscoped().Delete(&model.PlaybackHistory{}) if err := res.Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/repository/api_config_repository.go b/internal/repository/api_config_repository.go index c79417f..1a1f624 100644 --- a/internal/repository/api_config_repository.go +++ b/internal/repository/api_config_repository.go @@ -62,9 +62,9 @@ func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) er }).Error } -// Delete removes an API config. +// Delete 物理删除 API 配置。 func (r *ApiConfigRepository) Delete(ctx context.Context, provider string) error { - return r.db.WithContext(ctx).Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error + return r.db.WithContext(ctx).Unscoped().Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error } // UpdateTestResult 更新测试结果。 diff --git a/internal/repository/favorite_repository.go b/internal/repository/favorite_repository.go index e6c3770..40862ad 100644 --- a/internal/repository/favorite_repository.go +++ b/internal/repository/favorite_repository.go @@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string) if err != nil { return false, err } - return false, r.db.WithContext(ctx).Delete(&f).Error + return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error } // ListByUser returns all favourite media IDs for a user. diff --git a/internal/repository/library_repository.go b/internal/repository/library_repository.go index 3012509..2d9d90e 100644 --- a/internal/repository/library_repository.go +++ b/internal/repository/library_repository.go @@ -79,10 +79,9 @@ func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Lib return &l, nil } -// Delete removes a library and (soft) cascades to its media via repository -// callers; we do not run CASCADE here to keep this method narrow. +// Delete 物理删除媒体库。 func (r *LibraryRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.Library{}, "id = ?", id).Error + return r.db.WithContext(ctx).Unscoped().Delete(&model.Library{}, "id = ?", id).Error } func (r *LibraryRepository) ListRoots(ctx context.Context, libraryID string) ([]model.LibraryRoot, error) { @@ -149,7 +148,7 @@ func (r *LibraryRepository) DeleteRoot(ctx context.Context, libraryID, rootID st if !r.hasLibraryRootsTable() { return nil } - return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}, "id = ?", rootID).Error + return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}, "id = ?", rootID).Error } func (r *LibraryRepository) hasLibraryRootsTable() bool { diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index 5cadb7d..d0b6966 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -116,12 +116,12 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID // DeleteByLibrary purges all media tied to a library. func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error { - // FTS 行由 media 表上的触发器同步清理(软删/硬删都覆盖)。 - return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.Media{}).Error + // FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。 + return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.Media{}).Error } func (r *MediaRepository) DeleteByLibraryRoot(ctx context.Context, libraryID, rootID string) error { - return r.db.WithContext(ctx). + return r.db.WithContext(ctx).Unscoped(). Where("library_id = ? AND library_root_id = ?", libraryID, rootID). Delete(&model.Media{}).Error } diff --git a/internal/repository/permission_repository.go b/internal/repository/permission_repository.go index 44d095c..fc126af 100644 --- a/internal/repository/permission_repository.go +++ b/internal/repository/permission_repository.go @@ -51,9 +51,9 @@ func (r *PermissionRepository) Upsert(ctx context.Context, p *model.UserPermissi }) } -// Delete removes a permission record. +// Delete 物理删除权限记录。 func (r *PermissionRepository) Delete(ctx context.Context, userID string) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error + return r.db.WithContext(ctx).Unscoped().Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error }) } diff --git a/internal/repository/play_profile_repo.go b/internal/repository/play_profile_repo.go index a76981d..0cf9b86 100644 --- a/internal/repository/play_profile_repo.go +++ b/internal/repository/play_profile_repo.go @@ -59,9 +59,9 @@ func (r *PlayProfileRepository) Update(ctx context.Context, id string, patch map Where("id = ?", id).Updates(patch).Error } -// Delete soft-deletes a profile. +// Delete 物理删除播放档案。 func (r *PlayProfileRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.PlayProfile{}, "id = ?", id).Error + return r.db.WithContext(ctx).Unscoped().Delete(&model.PlayProfile{}, "id = ?", id).Error } // ClearDefaultsFor resets is_default for all of a user's profiles. diff --git a/internal/repository/refresh_token_repository.go b/internal/repository/refresh_token_repository.go index 5847e8a..33136de 100644 --- a/internal/repository/refresh_token_repository.go +++ b/internal/repository/refresh_token_repository.go @@ -72,10 +72,10 @@ func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, }) } -// DeleteExpired removes all expired refresh tokens. +// DeleteExpired 物理清理所有过期的 refresh tokens。 func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error + return r.db.WithContext(ctx).Unscoped().Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error }) } diff --git a/internal/repository/setting_repository.go b/internal/repository/setting_repository.go index c028eec..c3a7368 100644 --- a/internal/repository/setting_repository.go +++ b/internal/repository/setting_repository.go @@ -29,9 +29,9 @@ func (r *SettingRepository) Set(ctx context.Context, key, value string) error { return r.db.WithContext(ctx).Save(&s).Error } -// Delete removes a setting key. +// Delete 物理删除设置键。 func (r *SettingRepository) Delete(ctx context.Context, key string) error { - return r.db.WithContext(ctx).Where("key = ?", key).Delete(&model.Setting{}).Error + return r.db.WithContext(ctx).Unscoped().Where("key = ?", key).Delete(&model.Setting{}).Error } // All returns every key/value pair (used by the admin UI). diff --git a/internal/repository/storage_config_repo.go b/internal/repository/storage_config_repo.go index 403d808..81e7e2b 100644 --- a/internal/repository/storage_config_repo.go +++ b/internal/repository/storage_config_repo.go @@ -66,9 +66,9 @@ func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageCo }).Error } -// Delete removes a storage config by ID. +// Delete 物理删除存储配置。 func (r *StorageConfigRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StorageConfig{}).Error + return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StorageConfig{}).Error } // FindByID returns a storage config by ID. diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index b660651..c90b5dd 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -59,7 +59,7 @@ func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmAccount{}).Error + return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmAccount{}).Error }) } @@ -123,7 +123,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa func (r *StrmSyncPathRepository) Delete(ctx context.Context, id string) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmSyncPath{}).Error + return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmSyncPath{}).Error }) } @@ -286,7 +286,7 @@ func (r *StrmDownloadTaskRepository) Update(ctx context.Context, t *model.StrmDo func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error + return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error }) } @@ -294,7 +294,7 @@ func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) erro func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) { var count int64 err := withSQLiteBusyRetry(ctx, func() error { - res := r.db.WithContext(ctx).Where("status = ?", model.StrmTaskDone).Delete(&model.StrmDownloadTask{}) + res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskDone).Delete(&model.StrmDownloadTask{}) count = res.RowsAffected return res.Error }) @@ -305,7 +305,7 @@ func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, erro func (r *StrmDownloadTaskRepository) ClearFinished(ctx context.Context) (int64, error) { var count int64 err := withSQLiteBusyRetry(ctx, func() error { - res := r.db.WithContext(ctx).Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed}). + res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}). Delete(&model.StrmDownloadTask{}) count = res.RowsAffected return res.Error @@ -313,6 +313,17 @@ func (r *StrmDownloadTaskRepository) ClearFinished(ctx context.Context) (int64, return count, err } +// ClearCanceled 清空全部已取消下载任务。 +func (r *StrmDownloadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskCanceled).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 @@ -381,7 +392,7 @@ func (r *StrmDownloadTaskRepository) GetActiveLocalPathMap(ctx context.Context, func (r *StrmDownloadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?", + return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before). Delete(&model.StrmDownloadTask{}).Error }) @@ -522,10 +533,21 @@ func (r *StrmUploadTaskRepository) Update(ctx context.Context, t *model.StrmUplo func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error + return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error }) } +// ClearCanceled 清空全部已取消上传任务。 +func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskCanceled).Delete(&model.StrmUploadTask{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} + // CancelPending 批量取消所有排队中和进行中的任务。 func (r *StrmUploadTaskRepository) CancelPending(ctx context.Context) (int64, error) { now := time.Now() @@ -573,7 +595,7 @@ func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, sy func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?", + return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before). Delete(&model.StrmUploadTask{}).Error }) @@ -614,7 +636,7 @@ func (r *StrmDirCacheRepository) Set(ctx context.Context, syncPathID, dirID, pat func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error + return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error }) } diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index c92da72..ae6cf52 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -133,26 +133,17 @@ func (r *UserRepository) TouchLogin(ctx context.Context, id string) error { }) } -// Delete removes a user (soft-delete via gorm.DeletedAt), releases the unique -// username, and drops Telegram bindings so future re-created users bind cleanly. +// Delete 物理删除用户并级联清理其关联记录。 func (r *UserRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var user model.User - if err := tx.Where("id = ?", id).First(&user).Error; err != nil { - return err - } - released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000") - if len(released) > 64 { - sum := sha256.Sum256([]byte(user.ID + user.Username)) - base := user.Username - if len(base) > 43 { - base = base[:43] - } - released = base + "__deleted__" + hex.EncodeToString(sum[:])[:10] - } - if err := tx.Model(&model.User{}).Where("id = ?", id).Update("username", released).Error; err != nil { - return err - } - return tx.Delete(&model.User{}, "id = ?", id).Error + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + _ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.RefreshToken{}) + _ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.UserPermission{}) + _ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.PlayProfile{}) + _ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.PlaybackHistory{}) + _ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.Favorite{}) + _ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.UserDevice{}) + return tx.Unscoped().Delete(&model.User{}, "id = ?", id).Error + }) }) } diff --git a/internal/service/media_library.go b/internal/service/media_library.go index a9ae985..528e551 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -43,10 +43,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil { return err } - if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { - return err - } - return tx.Delete(&model.Library{}, "id = ?", id).Error + if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { + return err + } + return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error }) if err == nil { s.invalidateMediaCache(ctx) diff --git a/internal/service/media_recycle.go b/internal/service/media_recycle.go index 996533b..d90404d 100644 --- a/internal/service/media_recycle.go +++ b/internal/service/media_recycle.go @@ -10,25 +10,10 @@ import ( const maxRecycleBinRecords = 200 -// SoftDelete moves a media row to the recycle bin (gorm soft delete). -// The on-disk file is kept; admins can purge it later. +// SoftDelete 物理删除媒体记录(统一硬删除以降低 SQLite 存储与索引压力)。 func (s *MediaService) SoftDelete(ctx context.Context, id string) error { - media, err := s.repo.Media.FindByID(ctx, id) - if err != nil { - return err - } - if media != nil && isCloudMediaPath(media.Path) { - err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error - if err == nil { - s.invalidateMediaCache(ctx) - } - return err - } - err = s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Media{}).Error + err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error if err == nil { - if pruneErr := pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords); pruneErr != nil { - return pruneErr - } s.invalidateMediaCache(ctx) } return err diff --git a/internal/service/organizer_directory_versions.go b/internal/service/organizer_directory_versions.go index dfaa369..bbc9926 100644 --- a/internal/service/organizer_directory_versions.go +++ b/internal/service/organizer_directory_versions.go @@ -229,9 +229,9 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis o.log.Warn("organize replace remove existing failed", zap.String("path", e), zap.Error(err)) } - if o.repo != nil && o.repo.DB != nil { - _ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error - } + if o.repo != nil && o.repo.DB != nil { + _ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error + } } // Move staged file + sidecars into the final path. if err := os.Rename(stage, dst); err != nil { diff --git a/internal/service/organizer_reclassify_media_rows.go b/internal/service/organizer_reclassify_media_rows.go index 13a0a65..b4f546c 100644 --- a/internal/service/organizer_reclassify_media_rows.go +++ b/internal/service/organizer_reclassify_media_rows.go @@ -97,7 +97,7 @@ func (o *OrganizerService) deleteMediaRowForPath(ctx context.Context, path strin if o == nil || o.repo == nil || o.repo.DB == nil { return } - _ = o.repo.DB.WithContext(ctx).Where("path = ?", path).Delete(&model.Media{}).Error + _ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", path).Delete(&model.Media{}).Error } func (o *OrganizerService) mediaPathExists(ctx context.Context, path string) bool { diff --git a/internal/service/playback.go b/internal/service/playback.go index c46ee5a..bf5762a 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -196,18 +196,18 @@ func (p *PlaybackService) AddToPlaylist(ctx context.Context, playlistID, mediaID return p.repo.DB.Create(item).Error } -// RemoveFromPlaylist removes a media item from a playlist (idempotent). +// RemoveFromPlaylist 物理删除播放列表项(幂等)。 func (p *PlaybackService) RemoveFromPlaylist(ctx context.Context, playlistID, mediaID string) error { - return p.repo.DB. + return p.repo.DB.WithContext(ctx).Unscoped(). Where("playlist_id = ? AND media_id = ?", playlistID, mediaID). Delete(&model.PlaylistItem{}).Error } -// DeletePlaylist removes a playlist and all of its items. +// DeletePlaylist 物理删除播放列表及其全部条目。 func (p *PlaybackService) DeletePlaylist(ctx context.Context, playlistID string) error { - if err := p.repo.DB.Where("playlist_id = ?", playlistID). + if err := p.repo.DB.WithContext(ctx).Unscoped().Where("playlist_id = ?", playlistID). Delete(&model.PlaylistItem{}).Error; err != nil { return err } - return p.repo.DB.Where("id = ?", playlistID).Delete(&model.Playlist{}).Error + return p.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", playlistID).Delete(&model.Playlist{}).Error } diff --git a/internal/service/scanner_prune.go b/internal/service/scanner_prune.go index a42bbce..d5328ba 100644 --- a/internal/service/scanner_prune.go +++ b/internal/service/scanner_prune.go @@ -11,13 +11,12 @@ import ( "github.com/ShukeBta/MMTL/internal/model" ) -// RemovePath deletes the media row for a path that has disappeared from disk -// (incremental delete used by the watcher on Remove/Rename events). +// RemovePath 物理删除磁盘上已不存在的媒体记录。 func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) { if _, err := os.Stat(path); err == nil { return 0, nil // still exists; nothing to remove } - res := s.repo.DB.WithContext(ctx). + res := s.repo.DB.WithContext(ctx).Unscoped(). Where("path = ?", path). Delete(&model.Media{}) if res.Error == nil && res.RowsAffected > 0 { @@ -55,7 +54,7 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string } stale = append(stale, row.ID) } - return s.deleteMediaByIDs(ctx, stale, false) + return s.deleteMediaByIDs(ctx, stale, true) } func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) { @@ -92,7 +91,7 @@ func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID } stale = append(stale, row.ID) } - return s.deleteMediaByIDs(ctx, stale, false) + return s.deleteMediaByIDs(ctx, stale, true) } func pathBelongsToRoot(pathValue, rootPath string) bool { diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go index f0cc6b7..efe804c 100644 --- a/internal/service/strm_queue.go +++ b/internal/service/strm_queue.go @@ -501,6 +501,16 @@ func (s *StrmService) ClearFinishedDownloadTasks(ctx context.Context) (int64, er return s.repo.StrmDownload.ClearFinished(ctx) } +// ClearCanceledDownloadTasks 清空全部已取消的下载记录,返回删除数量。 +func (s *StrmService) ClearCanceledDownloadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmDownload.ClearCanceled(ctx) +} + +// ClearCanceledUploadTasks 清空全部已取消的上传记录,返回删除数量。 +func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmUpload.ClearCanceled(ctx) +} + // RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。 func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) { return s.repo.StrmDownload.RetryAllFailed(ctx) diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index d0d7856..aaf7362 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -143,6 +143,9 @@ export const strmAPI = { clearFinishedDownloads: () => api.post<{ deleted: number }>('/admin/strm/downloads/clear-finished').then((r) => r.data), + clearCanceledDownloads: () => + api.post<{ deleted: number }>('/admin/strm/downloads/clear-canceled').then((r) => r.data), + retryFailedDownloads: () => api.post<{ retried: number }>('/admin/strm/downloads/retry-failed').then((r) => r.data), @@ -162,6 +165,9 @@ export const strmAPI = { cancelPendingUploads: () => api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data), + clearCanceledUploads: () => + api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data), + retryUpload: (id: string) => api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data), } \ No newline at end of file diff --git a/web/src/pages/StrmQueuePage.tsx b/web/src/pages/StrmQueuePage.tsx index 417dd4c..b911bc7 100644 --- a/web/src/pages/StrmQueuePage.tsx +++ b/web/src/pages/StrmQueuePage.tsx @@ -127,6 +127,19 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) { 'border-amber-200 text-amber-600 hover:bg-amber-50', cancelAllPendingAction, ) + if (filter === 'canceled') + return batchBtn( + '清空已取消记录', + 'trash', + 'border-gray-200 text-rose-500 hover:bg-rose-50', + () => + runBatch( + isDownload + ? () => strmAPI.clearCanceledDownloads() + : () => strmAPI.clearCanceledUploads(), + `确定清空所有已取消的${isDownload ? '下载' : '上传'}记录?`, + ), + ) if (!isDownload) return null if (filter === 'done') return batchBtn(