mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
Fix drive form dirty state and media fallbacks
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() !== "");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user