package app import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "sync" "testing" "time" ) func TestDownloadQualityExplicitChoiceAndFallback(t *testing.T) { d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { return rankingHTTPResponse(request, http.StatusOK, "#EXTM3U\n#EXTINF:2,\nsynthetic.ts\n#EXT-X-ENDLIST\n"), nil }) low := providerMedia{URL: "https://media.example.test/low.mp4", Quality: 720} high := providerMedia{URL: "https://media.example.test/high.mp4", Quality: 1080} media := high media.Variants = []providerMedia{high, low, {URL: "file:///invalid", Quality: 2160}} master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=1080x1920\nhigh.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280\nlow.m3u8\n" for _, input := range []struct{ requested, expected int }{{0, 1080}, {2160, 1080}, {1080, 1080}, {900, 720}, {720, 720}, {480, 720}} { task := Task{DownloadQuality: input.requested} for _, source := range []providerMedia{media, {URL: "https://media.example.test/master.m3u8", Playlist: master}} { selected, err := d.selectDownloadQuality(context.Background(), task, source) if err != nil || selected.Quality != input.expected { t.Fatalf("requested %d: selected %d, expected %d: %v", input.requested, selected.Quality, input.expected, err) } } } events := readDiagnosticEvents(t, d.diagnostics.path) last := events[len(events)-1] if last.RequestedQuality != 480 || last.Quality != 720 { t.Fatal("fallback log lost requested and actual quality", last) } } func TestDownloadQualityRequestValidation(t *testing.T) { for _, input := range []struct { body string valid bool quality int }{ {`{"ids":["hongguo:7000000000000000001"]}`, true, 0}, {`{"ids":["hongguo:7000000000000000001"],"quality":720}`, true, 720}, {`{"ids":["hongguo:7000000000000000001"],"quality":-1}`, false, 0}, {`{"ids":["hongguo:7000000000000000001"],"quality":9999}`, false, 0}, {`{"ids":["hongguo:7000000000000000001"],"quality":"720"}`, false, 0}, {`{"ids":["hongguo:7000000000000000001"],"quality":720.5}`, false, 0}, {`{"ids":[]}`, false, 0}, {`{"ids":["hongguo:7000000000000000001"]} {}`, false, 0}, } { writer := httptest.NewRecorder() _, quality, valid := readDownloadRequest(writer, httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(input.body))) if valid != input.valid || valid && quality != nil && *quality != input.quality { t.Fatal(input.body, valid, quality, writer.Body.String()) } } } func downloadQualityApp(t *testing.T) *UIApp { t.Helper() d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { t.Error("download task fixture attempted a network request", request.URL) return nil, errors.New("fixture only") }) a := &UIApp{cfg: d.cfg, downloader: d, tasks: map[string]*UITask{}, statePath: filepath.Join(d.cfg.dataDirectory(), "ui-state.json")} a.cond = sync.NewCond(&a.mu) drama := Drama{ID: "hongguo:7000000000000000001", Source: sourceHongguo, SourceID: "7000000000000000001", Title: "无图画质任务"} a.dramas = []Drama{drama} client := d.hongguoClient() client.details[drama.SourceID] = hongguoDetailEntry{Drama: drama, Chapters: []Chapter{ {ID: "hongguo:7000000000000000001:7000000000000000002", Source: sourceHongguo, Title: "第 1 集", CurrentEpisode: rawEpisode(1)}, }, ExpiresAt: time.Now().Add(time.Hour)} return a } func waitDownloadQualityParse(t *testing.T, a *UIApp) []uiTaskView { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { a.mu.Lock() pending := len(a.parsingInFlight) > 0 views := a.taskViewsLocked() a.mu.Unlock() if !pending { return views } time.Sleep(5 * time.Millisecond) } t.Fatal("chapter parsing did not finish") return nil } func TestDownloadQualitySurvivesQueueRestartRetryAndUpdate(t *testing.T) { a := downloadQualityApp(t) body := `{"ids":["hongguo:7000000000000000001"],"quality":720}` writer := httptest.NewRecorder() a.handleDownload(writer, httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(body))) if writer.Code != http.StatusAccepted { t.Fatal(writer.Code, writer.Body.String()) } views := waitDownloadQualityParse(t, a) if len(views) != 1 || views[0].DownloadQuality != 720 { t.Fatal("queued task lost selected quality", views) } restarted := &UIApp{cfg: a.cfg, downloader: a.downloader, tasks: map[string]*UITask{}, statePath: a.statePath} restarted.cond = sync.NewCond(&restarted.mu) restarted.loadState() task := restarted.tasks[views[0].ID] if task == nil || task.Source.DownloadQuality != 720 { t.Fatal("restart lost quality", task) } task.Status = uiStatusFailed selection, _ := json.Marshal(map[string]any{"ids": []string{task.ID}}) restarted.handleRetry(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/retry", bytes.NewReader(selection))) if task.Status != uiStatusQueued || task.Source.DownloadQuality != 720 { t.Fatal("retry changed quality", task) } restarted.handlePause(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/pause", bytes.NewReader(selection))) restarted.handleResume(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/resume", bytes.NewReader(selection))) if task.Status != uiStatusQueued || task.Source.DownloadQuality != 720 { t.Fatal("pause/resume changed quality", task) } client := a.downloader.hongguoClient() entry := client.details["7000000000000000001"] entry.Chapters = append(entry.Chapters, Chapter{ID: "hongguo:7000000000000000001:7000000000000000003", Source: sourceHongguo, Title: "第 2 集", CurrentEpisode: rawEpisode(2)}) client.details["7000000000000000001"] = entry restarted.handleUpdate(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/update", strings.NewReader(`{"ids":["hongguo:7000000000000000001"]}`))) views = waitDownloadQualityParse(t, restarted) if len(views) != 2 || views[0].DownloadQuality != 720 || views[1].DownloadQuality != 720 { t.Fatal("new episode update did not retain collection quality", views) } } func TestDownloadQualityFailedChapterParseKeepsPreference(t *testing.T) { a := downloadQualityApp(t) client := a.downloader.hongguoClient() entry := client.details["7000000000000000001"] empty := entry empty.Chapters = nil client.details["7000000000000000001"] = empty a.handleDownload(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/download", strings.NewReader(`{"ids":["hongguo:7000000000000000001"],"quality":540}`))) views := waitDownloadQualityParse(t, a) if len(views) != 1 || views[0].Status != uiStatusFailed || views[0].DownloadQuality != 540 { t.Fatal("chapter failure lost quality", views) } client.details["7000000000000000001"] = entry selection, _ := json.Marshal(map[string]any{"ids": []string{views[0].ID}}) a.handleRetry(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/ui/tasks/retry", bytes.NewReader(selection))) views = waitDownloadQualityParse(t, a) if len(views) != 1 || views[0].Status != uiStatusQueued || views[0].DownloadQuality != 540 { t.Fatal("reparsed chapter lost quality", views) } } func TestDownloadQualityKeepsHighestAvailableSource(t *testing.T) { d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { if request.URL.String() != "https://media.example.test/high.m3u8" { t.Fatal("download requested an unselected quality", request.URL) } return rankingHTTPResponse(request, http.StatusOK, "#EXTM3U\n#EXTINF:2.0,\nsynthetic.ts\n#EXT-X-ENDLIST\n"), nil }) task := Task{DramaID: "hongguo:7000000000000000001", Index: 1} ctx := context.WithValue(context.Background(), playbackQualityKey{}, 720) low := providerMedia{URL: "https://media.example.test/low.mp4", Quality: 720} high := providerMedia{URL: "https://media.example.test/high.mp4", Quality: 1080} media := low media.Variants = []providerMedia{low, high} selected, err := d.selectDownloadQuality(ctx, task, media) if err != nil || selected.Quality != 1080 || selected.URL != high.URL { t.Fatal("playback quality reduced the download quality", selected.Quality, err) } selected, err = d.selectDownloadQuality(ctx, task, low) if err != nil || selected.Quality != 720 || selected.URL != low.URL { t.Fatal("download invented a higher source quality", selected, err) } master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=9000000,RESOLUTION=720x1280\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1080x1920\nhigh.m3u8\n" selected, err = d.selectDownloadQuality(ctx, task, providerMedia{URL: "https://media.example.test/master.m3u8", Playlist: master, Quality: 720}) if err != nil || selected.Quality != 1080 || strings.Contains(selected.Playlist, "low.m3u8") || !strings.Contains(selected.Playlist, "high.m3u8") { t.Fatal("download did not explicitly select the highest HLS resolution", selected, err) } events := readDiagnosticEvents(t, d.diagnostics.path) last := events[len(events)-1] if last.Event != "download.media" || last.Quality != 1080 || !reflect.DeepEqual(last.AvailableQualities, []int{1080, 720}) { t.Fatal("download log did not retain the source quality evidence", last) } } func TestDownloadPreserves1080pPixelsAndPackets(t *testing.T) { app, session := nativePlaybackFixture(t, "2", "1080x1920") source := session.tasks[0].OutPath body, err := os.ReadFile(source) if err != nil { t.Fatal(err) } server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path != "/fixture.mp4" { t.Error("download requested an unrelated resource") http.NotFound(writer, request) return } http.ServeContent(writer, request, "fixture.mp4", time.Time{}, bytes.NewReader(body)) })) defer server.Close() d := app.downloader d.client, d.cfg.Retries, d.cfg.SkipBytes = server.Client(), 1, 1 task := Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "合成原画下载样本", Index: 1, OutPath: filepath.Join(t.TempDir(), "download.mp4"), Chapter: Chapter{ID: "hongguo:7000000000000000001:1", Source: sourceHongguo, VideoURL: server.URL + "/fixture.mp4"}} ctx, cancel := context.WithTimeout(context.WithValue(context.Background(), playbackQualityKey{}, 720), 30*time.Second) defer cancel() if err := d.DownloadEpisode(ctx, task); err != nil { t.Fatal(err) } info, err := probeMergeMedia(ctx, d.cfg.FFmpeg, task.OutPath) if err != nil || info.width != 1080 || info.height != 1920 { t.Fatal("download reduced the source resolution", info, err) } if !reflect.DeepEqual(playbackFixturePackets(t, d.cfg.FFmpeg, source), playbackFixturePackets(t, d.cfg.FFmpeg, task.OutPath)) { t.Fatal("download re-encoded the original video or audio") } }