Fix drive form dirty state and media fallbacks

This commit is contained in:
nianzhibai
2026-06-05 14:42:12 +00:00
parent 2d907da07d
commit e1b8f0eae7
7 changed files with 161 additions and 4 deletions
+31
View File
@@ -189,6 +189,27 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
}
items = appendUniqueVideos(items, fallback, homePageSize)
}
if len(items) < homePageSize && len(excludeIDs) > 0 {
// The browser keeps a recent-video exclude list so normal refreshes do not
// repeat too quickly. On small libraries that list can cover every visible
// video; when that happens, start a new random round instead of returning
// an empty home section.
roundExclude := videoIDs(items)
fallback, err := s.Catalog.RandomVideosWithReadyThumbnailsExcluding(r.Context(), roundExclude, homePageSize-len(items))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
items = appendUniqueVideos(items, fallback, homePageSize)
}
if len(items) < homePageSize && len(excludeIDs) > 0 {
fallback, err := s.Catalog.RandomVideosExcluding(r.Context(), videoIDs(items), homePageSize-len(items))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
items = appendUniqueVideos(items, fallback, homePageSize)
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, mapVideos(items))
}
@@ -248,6 +269,16 @@ func appendUniqueVideos(dst []*catalog.Video, candidates []*catalog.Video, limit
return dst
}
func videoIDs(items []*catalog.Video) []string {
out := make([]string, 0, len(items))
for _, item := range items {
if item != nil && item.ID != "" {
out = append(out, item.ID)
}
}
return out
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
+57
View File
@@ -241,6 +241,63 @@ func TestHandleHomeExcludesRecentlyShownVideos(t *testing.T) {
}
}
func TestHandleHomeStartsNewRoundWhenRecentExcludesAllVisibleVideos(t *testing.T) {
ctx := 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)
}
})
now := time.Now()
excludes := make([]string, 0, homePageSize+2)
for i := 0; i < homePageSize+2; i++ {
id := "ready-video-" + strconv.Itoa(i)
excludes = append(excludes, "exclude="+id)
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: id,
DriveID: "drive",
FileID: id,
Title: id,
ThumbnailURL: "https://thumb.example/" + id + ".jpg",
PublishedAt: now.Add(time.Duration(i) * time.Minute),
CreatedAt: now.Add(time.Duration(i) * time.Minute),
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("seed ready video %s: %v", id, err)
}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/home?"+strings.Join(excludes, "&"), nil)
(&Server{Catalog: cat}).handleHome(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got []VideoDTO
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(got) != homePageSize {
t.Fatalf("home items = %d, want %d; body=%s", len(got), homePageSize, rr.Body.String())
}
seen := map[string]bool{}
for _, item := range got {
if seen[item.ID] {
t.Fatalf("home returned duplicate video %q; items=%#v", item.ID, got)
}
seen[item.ID] = true
if !strings.HasPrefix(item.ID, "ready-video-") {
t.Fatalf("home returned unexpected video %q; items=%#v", item.ID, got)
}
}
}
func TestHandleListLatestPrefersReadyThumbnails(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
-1
View File
@@ -1535,7 +1535,6 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "moov atom not found") ||
strings.Contains(text, "partial file") ||
strings.Contains(text, "service unavailable")
case "p123":
+29
View File
@@ -592,6 +592,35 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) {
}
}
func TestThumbWorkerPikPakMoovAtomErrorFailsWithoutCooldown(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-pikpak-missing-moov")
mediaErr := errors.New("ffprobe: exit status 1, stderr: moov atom not found Invalid data found when processing input")
gen := &fakeThumbGenerator{
probeErr: mediaErr,
generateErr: mediaErr,
}
drv := &previewFakeDrive{kind: "pikpak"}
worker := NewThumbWorker(gen, cat, drv)
worker.process(ctx, video)
failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0)
if err != nil {
t.Fatalf("list failed thumbnails: %v", err)
}
if len(failed) != 1 || failed[0].ID != video.ID {
t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID)
}
if !worker.Status().CooldownUntil.IsZero() {
t.Fatalf("cooldown until = %s, want no cooldown for invalid PikPak MP4", worker.Status().CooldownUntil)
}
if gen.generateCalls != 1 {
t.Fatalf("generate calls = %d, want 1", gen.generateCalls)
}
}
func TestPreviewWorkerP115TransientErrorKeepsVideoPending(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "preview-p115-transient")
+18 -2
View File
@@ -70,7 +70,9 @@ export function DrivesPage() {
const nightlyBusy = scanningAll || nightlyStatus.running || nightlyStatus.queued;
const nameMissing = form.name.trim().length === 0;
const nameError = nameTouched && nameMissing ? "请填写网盘名称" : "";
const formDirty = !sameForm(form, initialForm);
const formDirty = form.id
? !sameForm(form, initialForm)
: hasCreateFormChanges(form, initialForm);
const uploadTargets = useMemo(
() => list.filter((d) => d.kind === "pikpak" || d.kind === "p115" || d.kind === "p123" || d.kind === "onedrive"),
@@ -207,6 +209,13 @@ export function DrivesPage() {
setNameTouched(false);
}
function handleCreateFormChange(nextForm: FormState) {
setForm(nextForm);
if (!nextForm.id && !hasCreateFormChanges(nextForm, initialForm)) {
setInitialForm(nextForm);
}
}
async function handleSave() {
const name = form.name.trim();
if (!name || !form.kind) {
@@ -753,7 +762,7 @@ export function DrivesPage() {
>
<DriveForm
form={form}
onChange={setForm}
onChange={handleCreateFormChange}
isEdit={!!list.find((x) => x.id === form.id)}
uploadTargets={uploadTargets}
nameError={nameError}
@@ -804,3 +813,10 @@ function sameRecord(a: Record<string, string>, b: Record<string, string>): boole
}
return true;
}
function hasCreateFormChanges(form: FormState, initial: FormState): boolean {
if (form.name.trim() !== "") return true;
if (form.rootId.trim() !== "") return true;
if (form.spider91UploadDriveId !== initial.spider91UploadDriveId) return true;
return Object.values(form.creds).some((value) => value.trim() !== "");
}
+1 -1
View File
@@ -243,7 +243,7 @@ export function DriveGenerationPanel({
onClick={onRegenFailed}
>
<RotateCcw size={13} />
<span>{(d.teaserFailedCount ?? 0) > 0 ? "重试失败预览" : "继续生成预览"}</span>
<span>{(d.teaserFailedCount ?? 0) > 0 ? "重试失败预览视频" : "继续生成预览视频"}</span>
</button>
<button
className="admin-btn"
+25
View File
@@ -210,6 +210,31 @@ test("drive discard confirmation matches delete confirmation modal styling", ()
}
});
test("new drive type selection alone is not treated as unsaved config", () => {
assert.match(
drivesPageSource,
/const formDirty = form\.id\s*\?\s*!sameForm\(form, initialForm\)\s*:\s*hasCreateFormChanges\(form, initialForm\);/
);
assert.match(drivesPageSource, /function handleCreateFormChange\(nextForm: FormState\)/);
assert.match(
drivesPageSource,
/if \(!nextForm\.id && !hasCreateFormChanges\(nextForm, initialForm\)\) \{\s*setInitialForm\(nextForm\);/
);
assert.match(drivesPageSource, /onChange=\{handleCreateFormChange\}/);
const match = /function hasCreateFormChanges\(form: FormState, initial: FormState\): boolean \{([\s\S]*?)\n\}/.exec(
drivesPageSource
);
assert.ok(match, "create form dirty helper should be present");
const helper = match[1];
assert.match(helper, /form\.name\.trim\(\) !== ""/);
assert.match(helper, /form\.rootId\.trim\(\) !== ""/);
assert.match(helper, /form\.spider91UploadDriveId !== initial\.spider91UploadDriveId/);
assert.match(helper, /Object\.values\(form\.creds\)\.some/);
assert.doesNotMatch(helper, /form\.kind/);
});
test("drive generation actions can resume pending work after stop", () => {
assert.match(driveComponentsSource, /thumbnailPendingCount/);
assert.match(driveComponentsSource, /teaserPendingCount/);