Fix scanner cancellation and shorts UI

This commit is contained in:
nianzhibai
2026-06-06 08:37:00 +00:00
parent a770b3af6b
commit c87208117e
7 changed files with 129 additions and 11 deletions
+17 -1
View File
@@ -1444,7 +1444,15 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
log.Printf("[scan] drive=%s start=%s skip_dirs=%d", driveID, startID, len(d.SkipDirIDs))
stats, err := sc.Run(ctx, startID)
if err != nil {
log.Printf("[scan] drive=%s error: %v", driveID, err)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Printf("[scan] drive=%s canceled: %v", driveID, err)
} else {
log.Printf("[scan] drive=%s error: %v", driveID, err)
}
return
}
if err := ctx.Err(); err != nil {
log.Printf("[scan] drive=%s canceled after scan: %v", driveID, err)
return
}
log.Printf("[scan] drive=%s done scanned=%d added=%d errors=%d", driveID, stats.Scanned, stats.Added, stats.Errors)
@@ -1461,12 +1469,20 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
} else {
removed, err := a.cleanupMissingDriveVideos(ctx, driveID, stats.SeenFileIDs, stats.VisitedDirIDs, startID == drv.RootID())
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
log.Printf("[cleanup] canceled stale cleanup drive=%s kind=%s: %v", driveID, drv.Kind(), ctxErr)
return
}
log.Printf("[cleanup] stale cleanup drive=%s kind=%s error: %v", driveID, drv.Kind(), err)
} else if removed > 0 {
log.Printf("[cleanup] removed %d stale videos for drive=%s kind=%s", removed, driveID, drv.Kind())
}
}
}
if err := ctx.Err(); err != nil {
log.Printf("[scan] drive=%s canceled before enqueue generation: %v", driveID, err)
return
}
a.scheduleFingerprintBackfill(ctx, driveID, fingerprintWorker)
a.enqueueDriveGeneration(ctx, driveID, worker, thumbWorker)
}
+36
View File
@@ -127,6 +127,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
}
for _, e := range entries {
if err := ctx.Err(); err != nil {
return err
}
if e.IsDir {
// 跳过 previews 目录,避免扫到自己生成的预览视频
if strings.EqualFold(e.Name, "previews") {
@@ -137,6 +140,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
continue
}
if err := s.walk(ctx, e.ID, e.Name, stats, progress); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
stats.Errors++
log.Printf("[scanner] walk %s error: %v", e.Name, err)
}
@@ -155,6 +161,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
if deleted, err := s.Catalog.IsDeletedVideoCandidate(ctx, id, s.Drive.ID(), e.ID, e.Hash, e.Name, e.Size); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
stats.Errors++
log.Printf("[scanner] check deleted video %s error: %v", id, err)
continue
@@ -170,11 +179,20 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if matched, err := s.Catalog.MatchTags(ctx, e.Name+" "+dirName+" "+parsed.Author); err == nil {
tags = mergeTags(tags, matched)
}
if err := ctx.Err(); err != nil {
return err
}
if label, ok, err := s.Catalog.EnsureCollectionTag(ctx, dirName); err == nil && ok {
tags = mergeTags(tags, []string{label})
}
if err := ctx.Err(); err != nil {
return err
}
existing, _ := s.Catalog.GetVideo(ctx, id)
if err := ctx.Err(); err != nil {
return err
}
if existing != nil {
patch := catalog.VideoMetaPatch{}
if e.Hash != "" && existing.ContentHash == "" {
@@ -191,12 +209,21 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
}
if patch.Category != "" || patch.ContentHash != "" || patch.FileName != "" {
_ = s.Catalog.UpdateVideoMeta(ctx, id, patch)
if err := ctx.Err(); err != nil {
return err
}
}
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
continue
}
if err := ctx.Err(); err != nil {
return err
}
if !sameTags(existing.Tags, tags) {
_ = s.Catalog.SetAutoVideoTags(ctx, id, tags)
if err := ctx.Err(); err != nil {
return err
}
}
continue
}
@@ -204,6 +231,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
continue
}
if err := ctx.Err(); err != nil {
return err
}
now := time.Now()
v := &catalog.Video{
@@ -226,9 +256,15 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
UpdatedAt: now,
}
if err := s.Catalog.UpsertVideo(ctx, v); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
log.Printf("[scanner] upsert %s error: %v", v.Title, err)
continue
}
if err := ctx.Err(); err != nil {
return err
}
stats.Added++
if s.OnNewVideo != nil {
s.OnNewVideo(v)
+45
View File
@@ -3,6 +3,7 @@ package scanner
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
@@ -90,6 +91,50 @@ func TestRunIgnoresZeroSizeVideoFiles(t *testing.T) {
}
}
func TestRunStopsWhenContextCanceledDuringFileLoop(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{
{ID: "file-1", Name: "one.mp4", Size: 123},
{ID: "file-2", Name: "two.mp4", Size: 123},
{ID: "file-3", Name: "three.mp4", Size: 123},
},
}
callbacks := 0
sc := New(cat, drv, []string{".mp4"}, nil, func(*catalog.Video) {
callbacks++
cancel()
})
stats, err := sc.Run(ctx, "")
if !errors.Is(err, context.Canceled) {
t.Fatalf("scan error = %v, want context.Canceled", err)
}
if stats.Added != 1 || callbacks != 1 {
t.Fatalf("added=%d callbacks=%d, want exactly one video before cancellation", stats.Added, callbacks)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-1"); err != nil {
t.Fatalf("first video should be persisted before cancellation: %v", err)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-2"); err != sql.ErrNoRows {
t.Fatalf("second video lookup error = %v, want sql.ErrNoRows", err)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-3"); err != sql.ErrNoRows {
t.Fatalf("third video lookup error = %v, want sql.ErrNoRows", err)
}
}
func TestRunSkipsAdminDeletedVideo(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+17 -3
View File
@@ -809,12 +809,25 @@ function ShortsSlide({
}
}, [isMarkedHidden]);
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化
// MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位,之后才挂载 video;
// 因此这里必须跟随 shouldMount 重新绑定,否则后续视频没有 timeupdate 事件。
useEffect(() => {
if (!shouldMount) {
setDuration(0);
setCurrentTime(0);
setIsBuffering(false);
return;
}
const video = localRef.current;
if (!video) return;
const handleLoaded = () => {
if (Number.isFinite(video.duration)) setDuration(video.duration);
if (Number.isFinite(video.duration) && video.duration > 0) {
setDuration(video.duration);
} else {
setDuration(0);
}
if (!scrubbingRef.current) setCurrentTime(video.currentTime || 0);
};
const handleTime = () => {
// 拖动期间不要被 timeupdate 覆盖 UI
@@ -838,6 +851,7 @@ function ShortsSlide({
};
handleLoaded();
handleTime();
video.addEventListener("loadedmetadata", handleLoaded);
video.addEventListener("durationchange", handleLoaded);
video.addEventListener("timeupdate", handleTime);
@@ -860,7 +874,7 @@ function ShortsSlide({
video.removeEventListener("canplay", handlePlayingOrCanPlay);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [muted, volume, setMuted, setVolume]);
}, [shouldMount, item.id, muted, volume, setMuted, setVolume]);
// 长按 2 倍速:直接绑原生事件
useEffect(() => {
+1 -5
View File
@@ -392,11 +392,7 @@
line-height: 1.4;
font-weight: 600;
color: #fff;
/* 多层阴影叠加:近距离锐边 + 远距离弥散,浅色视频上也清楚 */
text-shadow:
0 1px 1px rgba(0, 0, 0, 0.9),
0 2px 5px rgba(0, 0, 0, 0.8),
0 4px 15px rgba(0, 0, 0, 0.6);
text-shadow: none;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
+1 -2
View File
@@ -220,11 +220,11 @@
font-weight: var(--weight-bold);
color: var(--text-strong);
letter-spacing: -0.005em;
text-shadow: none;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.vd-header__row {
@@ -1200,7 +1200,6 @@
-webkit-line-clamp: 3;
overflow-wrap: anywhere;
word-break: break-word;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.4);
}
.vd-header__row {
+12
View File
@@ -31,6 +31,18 @@ test("shorts progress dragging uses immediate pointer state", () => {
assert.match(shortsPageSource, /onLostPointerCapture=\{handleProgressPointerEnd\}/);
});
test("shorts progress listeners rebind when deferred videos mount", () => {
assert.match(
shortsPageSource,
/MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位/
);
assert.match(shortsPageSource, /if \(!shouldMount\) \{\s*setDuration\(0\);\s*setCurrentTime\(0\);/);
assert.match(
shortsPageSource,
/\}, \[shouldMount, item\.id, muted, volume, setMuted, setVolume\]\);/
);
});
test("shorts fullscreen changes preserve the active slide", () => {
assert.match(shortsPageSource, /const activeIndexRef = useRef\(0\)/);
assert.match(shortsPageSource, /const ignoreIntersectionUntilRef = useRef\(0\)/);