package app import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "sync" "testing" "time" ) const historyFixtureDramaID = "hongguo:7000000000000000001" func historyFixtureApp(t *testing.T) (*UIApp, *playbackSession) { t.Helper() app := &UIApp{cfg: Config{dataDir: t.TempDir()}, playbacks: make(map[string]*playbackSession)} session := &playbackSession{id: "history-fixture", viewer: fixtureViewer(app), openedAt: time.Now().Add(-time.Minute), run: 1, currentIndex: 1, state: "ended", expires: time.Now().Add(time.Hour), historyRuns: map[uint64]playbackHistoryRun{1: {episode: 1, duration: 90}}} for index := 1; index <= 3; index++ { session.tasks = append(session.tasks, Task{DramaID: historyFixtureDramaID, DramaTitle: "本地无图续播测试", Index: index, Total: 3, Chapter: Chapter{ID: fmt.Sprintf("hongguo:7000000000000000001:%d", index), CurrentEpisode: rawEpisode(index)}}) } session.timer = time.AfterFunc(time.Hour, func() {}) session.viewer.retain() app.playbacks[session.id] = session t.Cleanup(app.closePlaybacks) return app, session } func historyProgress(sequence uint64, position float64) playbackHistoryProgress { return playbackHistoryProgress{Run: 1, Sequence: sequence, Episode: 1, Position: position, Duration: 90} } func historyJSONRequest(t *testing.T, app *UIApp, handler http.HandlerFunc, path string, input any) *httptest.ResponseRecorder { t.Helper() body, err := json.Marshal(input) if err != nil { t.Fatal(err) } request := httptest.NewRequest(http.MethodPost, "http://localhost"+path, bytes.NewReader(body)) request.Header.Set("Content-Type", "application/json") writer := httptest.NewRecorder() handler(writer, viewerFixtureRequest(app, request)) return writer } func TestPlaybackHistoryPersistsAndSharesOnlineCollectionProgress(t *testing.T) { app, session := historyFixtureApp(t) if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 23.75)); err != nil || !saved { t.Fatal("initial progress was not saved", saved, err) } app.playbackMu.Lock() session.downloadIDs = []string{"download-one", "download-two", "download-three"} app.playbackMu.Unlock() if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 41.25)); err != nil || !saved { t.Fatal("collection progress was not saved", saved, err) } restarted := newPlaybackHistoryStore(fixtureViewer(app).directory) entries, err := restarted.list() if err != nil || len(entries) != 1 || entries[0].Position != 41.25 || entries[0].Mode != "collection" || entries[0].TaskID != "download-one" || entries[0].DramaID != historyFixtureDramaID { t.Fatalf("restart lost or duplicated progress: %+v %v", entries, err) } info, err := os.Stat(restarted.path) if err != nil || runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { t.Fatal("history file permissions", info, err) } body, _ := os.ReadFile(restarted.path) for _, field := range []string{"videoUrl", "cover", "poster", "sessionID", "sessionOpened"} { if bytes.Contains(body, []byte(field)) { t.Fatalf("unexpected runtime/media field persisted: %s", field) } } } func TestPlaybackHistoryRejectsPrefetchAndOutOfOrderProgress(t *testing.T) { app, session := historyFixtureApp(t) preloaded := historyProgress(1, 30) preloaded.Episode = 2 if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, preloaded); err != nil || saved { t.Fatal("an unplayed episode became history", saved, err) } if _, err := os.Stat(fixtureViewer(app).playbackHistory().path); !errors.Is(err, os.ErrNotExist) { t.Fatal("prefetch created a history file", err) } if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 45)); err != nil || !saved { t.Fatal(saved, err) } app.playbackMu.Lock() session.run, session.currentIndex = 2, 2 session.historyRuns[2] = playbackHistoryRun{episode: 2, duration: 90} app.playbackMu.Unlock() next := historyProgress(3, 12) next.Run, next.Episode = 2, 2 if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, next); err != nil || !saved { t.Fatal(saved, err) } if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 80)); err != nil || saved { t.Fatal("late previous-episode report overwrote progress", saved, err) } entry, _ := fixtureViewer(app).playbackHistory().get(historyFixtureDramaID) if entry.Episode != "2" || entry.Position != 12 { t.Fatalf("latest episode was not kept: %+v", entry) } } func TestPlaybackHistoryCloseSavesBeforeSessionRelease(t *testing.T) { app, session := historyFixtureApp(t) writer := historyJSONRequest(t, app, app.handlePlaybackControl, "/api/ui/playback/control", map[string]any{"session": session.id, "action": "close", "progress": historyProgress(1, 63.125)}) if writer.Code != 200 || strings.Contains(writer.Body.String(), `"historyError":"观看`) { t.Fatal(writer.Code, writer.Body.String()) } if _, exists := app.playbackStatus(session.id, false); exists { t.Fatal("closing history retained the playback session") } entry, found := newPlaybackHistoryStore(fixtureViewer(app).directory).get(historyFixtureDramaID) if !found || entry.Position != 63.125 { t.Fatalf("close snapshot was lost: %+v", entry) } } func TestPlaybackHistoryDeleteAndClearBlockLateAutosaves(t *testing.T) { for _, all := range []bool{false, true} { t.Run(fmt.Sprint(all), func(t *testing.T) { app, session := historyFixtureApp(t) if _, _, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 20)); err != nil { t.Fatal(err) } input := map[string]any{"dramaId": historyFixtureDramaID} if all { input = map[string]any{"all": true} } writer := historyJSONRequest(t, app, app.handlePlaybackHistoryRemove, "/api/ui/playback/history/remove", input) if writer.Code != 200 { t.Fatal(writer.Body.String()) } if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 25)); err != nil || saved { t.Fatal("deleted history was resurrected", saved, err) } if entries, _ := newPlaybackHistoryStore(fixtureViewer(app).directory).list(); len(entries) != 0 { t.Fatal("deleted history was persisted again") } app.playbackMu.Lock() session.openedAt = time.Now().Add(time.Millisecond) app.playbackMu.Unlock() if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(3, 5)); err != nil || !saved { t.Fatal("explicit new viewing could not create history", saved, err) } }) } } func TestPlaybackHistoryNewSessionWinsOverLateOldClose(t *testing.T) { app, old := historyFixtureApp(t) newer := *old newer.id, newer.openedAt, newer.timer = "new-viewing", time.Now(), time.AfterFunc(time.Hour, func() {}) newer.viewer.retain() app.playbacks[newer.id] = &newer if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), newer.id, historyProgress(1, 8)); err != nil || !saved { t.Fatal(saved, err) } if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), old.id, historyProgress(8, 60)); err != nil || saved { t.Fatal("older window replaced the new viewing", saved, err) } entry, _ := fixtureViewer(app).playbackHistory().get(historyFixtureDramaID) if entry.Position != 8 { t.Fatal(entry) } } func TestPlaybackHistoryResumeUsesChapterIdentityAndActualCompletion(t *testing.T) { _, session := historyFixtureApp(t) entry := playbackHistoryEntry{ChapterID: session.tasks[1].Chapter.ID, Episode: "2", Index: 2, Total: 3, Position: 42.5, Duration: 90} index, offset, paused, _ := playbackHistoryResume(entry, session.tasks, 1) if index != 2 || offset != 42.5 || paused { t.Fatal("normal resume", index, offset, paused) } partial := []Task{session.tasks[1], session.tasks[2]} index, offset, _, _ = playbackHistoryResume(entry, partial, 1) if index != 1 || offset != 42.5 { t.Fatal("partial collection used a global array index", index, offset) } entry.Position, entry.Completed = 89, false index, offset, _, _ = playbackHistoryResume(entry, session.tasks, 1) if index != 2 || offset != 89 { t.Fatal("near-end viewing skipped unwatched content", index, offset) } entry.Completed = true index, offset, paused, _ = playbackHistoryResume(entry, session.tasks, 1) if index != 3 || offset != 0 || paused { t.Fatal("completed episode did not advance", index, offset, paused) } entry.ChapterID, entry.Episode, entry.Index = session.tasks[2].Chapter.ID, "3", 3 index, offset, paused, message := playbackHistoryResume(entry, session.tasks, 1) if index != 3 || offset != 0 || !paused || !strings.Contains(message, "已看至最新") { t.Fatal("completed latest episode replayed automatically", index, offset, paused, message) } entry.ChapterID, entry.Episode, entry.Completed = "removed", "99", false index, offset, _, message = playbackHistoryResume(entry, session.tasks, 1) if index != 1 || offset != 0 || !strings.Contains(message, "没有上次观看") { t.Fatal("missing chapter reused an unrelated offset", index, offset, message) } } func TestPlaybackHistoryStorageDoesNotHoldPlaybackLock(t *testing.T) { app, session := historyFixtureApp(t) store := fixtureViewer(app).playbackHistory() store.writeMu.Lock() done := make(chan error, 1) go func() { _, _, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 10)) done <- err }() deadline := time.After(time.Second) for { if _, exists := store.get(historyFixtureDramaID); exists { break } select { case <-deadline: store.writeMu.Unlock() t.Fatal("history did not reach storage") case <-time.After(time.Millisecond): } } playback := make(chan bool, 1) go func() { _, exists := app.playbackStatus(session.id, false); playback <- exists }() select { case exists := <-playback: if !exists { t.Error("playback disappeared") } case <-time.After(time.Second): t.Error("slow history storage blocked playback") } store.writeMu.Unlock() if err := <-done; err != nil { t.Fatal(err) } } func TestPlaybackHistoryWriteFailureAndCorruptFilePreservation(t *testing.T) { app, session := historyFixtureApp(t) store := fixtureViewer(app).playbackHistory() originalPath := store.path store.path = app.cfg.dataDirectory() if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, 12)); err == nil || saved { t.Fatal("storage failure was silently reported as saved", saved, err) } if _, exists := app.playbackStatus(session.id, false); !exists { t.Fatal("history storage failure ended playback") } store.path = originalPath if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(2, 12)); err != nil || !saved { t.Fatal("failed write could not be retried", saved, err) } broken := []byte(`{"version":1,"entries":`) if err := os.WriteFile(originalPath, broken, 0o600); err != nil { t.Fatal(err) } restarted := newPlaybackHistoryStore(fixtureViewer(app).directory) if _, err := restarted.list(); err == nil { t.Fatal("corrupt history was reported as an empty success") } if _, err := restarted.record(playbackHistoryEntry{DramaID: historyFixtureDramaID}, time.Now()); err == nil { t.Fatal("corrupt history was overwritten") } actual, _ := os.ReadFile(originalPath) if !bytes.Equal(actual, broken) { t.Fatal("unreadable existing history changed") } } func TestPlaybackHistoryRecentLimitAndIsolatedDirectories(t *testing.T) { directory := t.TempDir() store := newPlaybackHistoryStore(directory) opened := time.Now() for index := 0; index < playbackHistoryLimit+2; index++ { entry := playbackHistoryEntry{DramaID: fmt.Sprintf("hongguo:%d", int64(7000000000000000000)+int64(index)), Title: "本地测试", Episode: "1", Index: 1, Total: 1, WatchedAt: opened.Add(time.Duration(index) * time.Second)} if _, err := store.record(entry, opened); err != nil { t.Fatal(err) } } if err := store.flush(); err != nil { t.Fatal(err) } entries, _ := newPlaybackHistoryStore(directory).list() if len(entries) != playbackHistoryLimit || !entries[0].WatchedAt.After(entries[len(entries)-1].WatchedAt) { t.Fatal("recent-history limit/order", len(entries)) } other := newPlaybackHistoryStore(filepath.Join(directory, "another-project")) if entries, _ := other.list(); len(entries) != 0 { t.Fatal("different data directories shared history") } } func TestPlaybackHistoryLoadRejectsMismatchedSource(t *testing.T) { directory := t.TempDir() entry := playbackHistoryEntry{DramaID: historyFixtureDramaID, Source: "unrelated-provider", Episode: "1", Index: 1, Total: 3, WatchedAt: time.Now()} body, err := json.Marshal(playbackHistoryFile{Version: 1, Entries: []playbackHistoryEntry{entry}}) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(directory, "playback-history.json"), body, 0o600); err != nil { t.Fatal(err) } entries, err := newPlaybackHistoryStore(directory).list() if err != nil || len(entries) != 0 { t.Fatal("history restored an unrelated source", entries, err) } } func TestPlaybackHistoryRejectsCrossSiteAndInvalidProgress(t *testing.T) { app, session := historyFixtureApp(t) request := httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/history", nil) request.Header.Set("Origin", "https://unrelated.invalid") writer := httptest.NewRecorder() app.handlePlaybackHistory(writer, viewerFixtureRequest(app, request)) if writer.Code != http.StatusForbidden { t.Fatal("cross-site history was allowed", writer.Code) } for _, position := range []float64{-1, 91 * 60 * 60} { if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, historyProgress(1, position)); err == nil || saved { t.Fatal("invalid position", position, saved, err) } } progress := historyProgress(1, 20) progress.Completed = true if entry, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, progress); err != nil || !saved || entry.Completed { t.Fatal("early completion flag skipped content", entry.Completed, saved, err) } } func TestPlaybackHistoryOpenResumesAndHonorsExplicitEpisode(t *testing.T) { app, session := historyFixtureApp(t) program, err := os.Executable() if err != nil { t.Fatal(err) } app.downloader = rankingTestDownloader(t, func(*http.Request) (*http.Response, error) { t.Error("history/open requested an external resource") return nil, errors.New("external request forbidden") }) app.downloader.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: program}} client := app.downloader.hongguoClient() chapters := make([]Chapter, len(session.tasks)) app.tasks = make(map[string]*UITask) for index, task := range session.tasks { task.Chapter.VideoURL = "hongguo-cenc://9000000000000000001" chapters[index] = task.Chapter id := fmt.Sprintf("download-%d", index+1) app.tasks[id] = &UITask{ID: id, DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Chapter.EpisodeString(task.Index), Source: task} app.taskOrder = append(app.taskOrder, id) } client.details["7000000000000000001"] = hongguoDetailEntry{Drama: Drama{ID: historyFixtureDramaID, Title: "本地无图续播测试"}, Chapters: chapters, ExpiresAt: time.Now().Add(time.Hour)} session.historyRuns[2] = playbackHistoryRun{episode: 2, duration: 90} progress := historyProgress(1, 32.5) progress.Episode, progress.Run = 2, 2 if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), session.id, progress); err != nil || !saved { t.Fatal(saved, err) } app.closePlayback(session.id) fixtureViewer(app).history = nil fixtureViewer(app).historyOnce = sync.Once{} check := func(input map[string]any, index int, position float64, mode string) { t.Helper() writer := historyJSONRequest(t, app, app.handlePlaybackOpen, "/api/ui/playback/open", input) if writer.Code != 200 { t.Fatal(writer.Code, writer.Body.String()) } var view struct { Session string `json:"session"` DramaID string `json:"dramaId"` InitialIndex int `json:"initialIndex"` InitialPosition float64 `json:"initialPosition"` Mode string `json:"mode"` } if err := json.Unmarshal(writer.Body.Bytes(), &view); err != nil { t.Fatal(err) } defer app.closePlayback(view.Session) if view.DramaID != historyFixtureDramaID || view.InitialIndex != index || view.InitialPosition != position || view.Mode != mode { t.Fatalf("unexpected playback selection: %+v", view) } } check(map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}, 2, 32.5, "online") check(map[string]any{"taskId": "download-1", "resume": true}, 2, 32.5, "collection") check(map[string]any{"taskId": "download-3", "resume": false}, 3, 0, "collection") entry, _ := fixtureViewer(app).playbackHistory().get(historyFixtureDramaID) entry.Mode, entry.TaskID, entry.Completed, entry.Position = "collection", "download-2", true, 90 if _, err := fixtureViewer(app).playbackHistory().record(entry, time.Now()); err != nil { t.Fatal(err) } check(map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}, 3, 0, "collection") app.tasks = nil check(map[string]any{"dramaId": historyFixtureDramaID, "resume": true, "fromHistory": true}, 3, 0, "online") }