This commit is contained in:
truewhile
2026-10-11 21:29:19 +08:00
parent 0db8698fce
commit 6186c8b848
2 changed files with 149 additions and 6 deletions
+49 -6
View File
@@ -115,7 +115,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return m.ID, nil
}
updates := mediaUpsertUpdates(existing, *m)
// 孤儿行回收:原库已被删除时(DeleteLibrary 硬删库行;扫描与删除并发时扫描也会
// 把行写回已删库),这条行会钉死在不存在的 library_id 上。media.path 是全局唯一
// 索引,重扫既插不进同路径新行,又因 LibraryID 非空而拒绝迁移归属,于是新库/重建库
// 永远查不到这些条目(表现为"扫描明明有文件,库却是空的")。这里显式识别并允许迁移。
reclaimLibrary := r.mediaRowLibraryIsOrphaned(ctx, db, existing.LibraryID, m.LibraryID, m.Path)
updates := mediaUpsertUpdates(existing, *m, reclaimLibrary)
if adopted {
updates["path"] = m.Path
if existing.DeletedAt.Valid {
@@ -139,6 +144,42 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return existing.ID, nil
}
// mediaRowLibraryIsOrphaned reports whether a media row is still pinned to a
// library that no longer exists.
//
// 删除媒体库会硬删库行(见 MediaService.DeleteLibrary),但扫描与删除并发时,
// 已在途的扫描仍可能把这批文件写回已删库的 library_id;历史数据里也可能留下
// 指向早已消失的库的行。因为 media.path 是全局唯一索引,这些行一旦存在:
// 1. 重扫无法插入同路径的新行(唯一约束冲突);
// 2. addMediaPlacementUpdates 又因为 existing.LibraryID 非空而拒绝迁移归属。
//
// 结果就是新库 / 重建的同名库永远查不到这些条目,扫描报告有文件而库显示为空。
// 这里把"原库已不存在"这种情况单独判定出来,交给扫描把归属迁移到当前库。
func (r *MediaRepository) mediaRowLibraryIsOrphaned(ctx context.Context, db *gorm.DB, existingLibraryID, incomingLibraryID, incomingPath string) bool {
existingLibraryID = strings.TrimSpace(existingLibraryID)
incomingLibraryID = strings.TrimSpace(incomingLibraryID)
// 归属未变或原行为空(后者走既有的回填分支)时无需查库。
if existingLibraryID == "" || existingLibraryID == incomingLibraryID {
return false
}
// 云盘媒体的归属迁移由 addMediaPlacementUpdates 无条件处理,无需再查库。
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(incomingPath)), "cloud://") {
return false
}
if db == nil || !db.Migrator().HasTable(&model.Library{}) {
return false
}
var count int64
// 刻意不加 Unscoped:软删除的库同样不再出现在库里,挂在它下面的媒体行
// 也属于"查不到"的孤儿,一并允许回收。查询失败时保守处理:宁可保留旧
// 归属,也不要把仍有效的库误判成孤儿。
if err := db.WithContext(ctx).Model(&model.Library{}).
Where("id = ?", existingLibraryID).Count(&count).Error; err != nil {
return false
}
return count == 0
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
@@ -194,14 +235,14 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
return existing, err
}
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
updates := map[string]any{}
addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming)
addMatchedMediaMetadataUpdates(updates, existing, incoming)
addMediaArtworkUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming, reclaimLibrary)
addMediaSTRMUpdate(updates, existing, incoming)
return updates
}
@@ -308,13 +349,15 @@ func addMediaArtworkUpdates(updates map[string]any, existing, incoming model.Med
}
}
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media) {
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media, reclaimLibrary bool) {
// 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级
// 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库,
// 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。
// 本地媒体物理位置固定:仅在原 library_id 为空时回填,不迁移。
// 本地媒体物理位置固定:仅在原 library_id 为空、或原库已不存在(孤儿行,
// reclaimLibrary)时才回填/迁移,既避免把仍属于其他库的本地媒体抢走,
// 又能让扫描回收被删除库遗留的行。
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://"); incoming.LibraryID != "" && incoming.LibraryID != existing.LibraryID {
if isCloudMediaPath || existing.LibraryID == "" {
if isCloudMediaPath || existing.LibraryID == "" || reclaimLibrary {
updates["library_id"] = incoming.LibraryID
}
}
@@ -282,6 +282,106 @@ func TestScanLibraryUpdatesExistingPathFromOverlappingLibrary(t *testing.T) {
}
}
// TestScanLibraryReclaimsMediaOrphanedByDeletedLibrary covers the "scan reports
// files but the library stays empty" failure: a row left behind by a deleted
// library keeps its stale library_id, and because media.path carries a global
// unique index the rescan can neither insert a fresh row nor rewrite the
// ownership, so a rebuilt library stays empty forever. The scan must reclaim
// such orphaned rows for the library being scanned.
func TestScanLibraryReclaimsMediaOrphanedByDeletedLibrary(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := t.TempDir()
file := filepath.Join(root, "Orphaned Episode 1.mkv")
if err := os.WriteFile(file, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
oldLib := model.Library{Name: "Old", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &oldLib); err != nil {
t.Fatal(err)
}
if _, err := sc.ScanLibrary(t.Context(), oldLib.ID); err != nil {
t.Fatalf("first scan: %v", err)
}
// Reproduce the gap the scan cannot see: the library row is gone while its
// media rows survive (hard-deleted library, or an in-flight scan writing
// after DeleteLibrary).
if err := repos.DB.Unscoped().Delete(&model.Library{}, "id = ?", oldLib.ID).Error; err != nil {
t.Fatal(err)
}
if got := countMediaInLibrary(t, repos, oldLib.ID); got != 1 {
t.Fatalf("precondition: rows still pinned to deleted library = %d, want 1", got)
}
newLib := model.Library{Name: "New", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &newLib); err != nil {
t.Fatal(err)
}
res, err := sc.ScanLibrary(t.Context(), newLib.ID)
if err != nil {
t.Fatalf("rescan: %v", err)
}
if res.ErrorCount != 0 {
t.Fatalf("rescan errors = %#v, want none", res.Errors)
}
if got := countMedia(t, repos); got != 1 {
t.Fatalf("media count = %d, want 1 (orphan reclaimed, not duplicated)", got)
}
if got := countMediaInLibrary(t, repos, newLib.ID); got != 1 {
t.Fatalf("rebuilt library media count = %d, want 1", got)
}
var media model.Media
if err := repos.DB.First(&media, "path = ?", file).Error; err != nil {
t.Fatal(err)
}
if media.LibraryID != newLib.ID {
t.Fatalf("media library_id = %q, want reclaimed to %q", media.LibraryID, newLib.ID)
}
}
// TestScanLibraryKeepsOwnershipWhenOwningLibraryStillExists pins the other half
// of the rule: local media stays with its existing library as long as that
// library is real, so overlapping libraries do not steal each other's rows.
func TestScanLibraryKeepsOwnershipWhenOwningLibraryStillExists(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := t.TempDir()
file := filepath.Join(root, "Shared (2026).mkv")
if err := os.WriteFile(file, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
ownerLib := model.Library{Name: "Owner", Path: root, Type: "movie", Enabled: true}
otherLib := model.Library{Name: "Other", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &ownerLib); err != nil {
t.Fatal(err)
}
if err := repos.Library.Create(t.Context(), &otherLib); err != nil {
t.Fatal(err)
}
if _, err := sc.ScanLibrary(t.Context(), ownerLib.ID); err != nil {
t.Fatalf("owner scan: %v", err)
}
if _, err := sc.ScanLibrary(t.Context(), otherLib.ID); err != nil {
t.Fatalf("other scan: %v", err)
}
var media model.Media
if err := repos.DB.First(&media, "path = ?", file).Error; err != nil {
t.Fatal(err)
}
if media.LibraryID != ownerLib.ID {
t.Fatalf("media library_id = %q, want unchanged owner %q", media.LibraryID, ownerLib.ID)
}
}
func countMediaInLibrary(t *testing.T, repos *repository.Container, libraryID string) int64 {
t.Helper()
var n int64
if err := repos.DB.Model(&model.Media{}).Where("library_id = ?", libraryID).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func TestScanLibrarySkipsUnchangedLocalMetadata(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := t.TempDir()