From d95c9fe77d24cba5ff87423e4cc905fb9155153f Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:01:47 +0900 Subject: [PATCH] fix(peertube): read every page of the channel and comment-thread lists Two PeerTube lists were read as a single page. The account's channels (GET /api/v1/accounts/{name}/video-channels) were requested without count or start, so PeerTube returned its default page of 15 and channels past the fifteenth could not be connected or picked, although the default quota is 20. A video's comment threads were requested as one page of 100 and never followed start, so on a busy video the older threads, and new replies on them, never reached the inbox. Both now go through one helper that requests the largest page PeerTube accepts and follows start until total. Co-Authored-By: Claude Opus 5 --- apps/server/internal/platform/peertube.go | 64 ++++++----- .../server/internal/platform/peertube_test.go | 104 ++++++++++++++++++ changes/peertube-channel-pages.md | 4 + 3 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 changes/peertube-channel-pages.md diff --git a/apps/server/internal/platform/peertube.go b/apps/server/internal/platform/peertube.go index 573fab5ec..5527052fb 100644 --- a/apps/server/internal/platform/peertube.go +++ b/apps/server/internal/platform/peertube.go @@ -198,6 +198,39 @@ type peertubeChannel struct { DisplayName string `json:"displayName"` } +// peerTubeListPageSize is the largest page PeerTube's paginated lists +// accept. Without a count they stop at 15. +const peerTubeListPageSize = 100 + +// listPeerTubePages reads every page of a paginated PeerTube list, which +// answers {total, data} and is addressed by start and count. +func listPeerTubePages[T any](ctx context.Context, endpoint, accessToken, label string) ([]T, error) { + var items []T + for start := 0; ; { + var page struct { + Total int64 `json:"total"` + Data []T `json:"data"` + } + query := url.Values{"count": {strconv.Itoa(peerTubeListPageSize)}, "start": {strconv.Itoa(start)}} + body, err := DoRequest(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil, map[string]string{ + headerAuthorization: bearerPrefix + accessToken, + }) + if err != nil { + return nil, fmt.Errorf("listing peertube %s: %w", label, err) + } + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("decoding peertube %s: %w", label, err) + } + items = append(items, page.Data...) + start += len(page.Data) + // An empty page also ends the list, so a total that overcounts + // cannot keep it requesting. + if len(page.Data) == 0 || int64(start) >= page.Total { + return items, nil + } + } +} + func (p *PeerTubeAdapter) listOwnChannels(ctx context.Context, accessToken string) ([]peertubeChannel, error) { me, err := p.fetchMe(ctx, accessToken) if err != nil { @@ -207,19 +240,7 @@ func (p *PeerTubeAdapter) listOwnChannels(ctx context.Context, accessToken strin if accountName == "" { return nil, fmt.Errorf("peertube account identity is unavailable") } - var result struct { - Data []peertubeChannel `json:"data"` - } - body, err := DoRequest(ctx, http.MethodGet, p.instanceURL+"/api/v1/accounts/"+url.PathEscape(accountName)+"/video-channels", nil, map[string]string{ - headerAuthorization: bearerPrefix + accessToken, - }) - if err != nil { - return nil, fmt.Errorf("listing peertube channels: %w", err) - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("decoding peertube channels: %w", err) - } - return result.Data, nil + return listPeerTubePages[peertubeChannel](ctx, p.instanceURL+"/api/v1/accounts/"+url.PathEscape(accountName)+"/video-channels", accessToken, "channels") } func (p *PeerTubeAdapter) ListAccountSelections(ctx context.Context, token *TokenResult) ([]AccountSelectionOption, error) { @@ -611,18 +632,9 @@ func (p *PeerTubeAdapter) ListComments(ctx context.Context, accessToken, _ strin } // The thread list carries each thread's first comment as a plain comment. // Replies are only returned by the per-thread tree endpoint. - var result struct { - Data []peertubeComment `json:"data"` - } - query := url.Values{"count": {strconv.Itoa(peerTubeCommentPageSize)}} - body, err := DoRequest(ctx, http.MethodGet, p.instanceURL+"/api/v1/videos/"+url.PathEscape(videoID)+"/comment-threads?"+query.Encode(), nil, map[string]string{ - headerAuthorization: bearerPrefix + accessToken, - }) + threads, err := listPeerTubePages[peertubeComment](ctx, p.instanceURL+"/api/v1/videos/"+url.PathEscape(videoID)+"/comment-threads", accessToken, "comments") if err != nil { - return nil, fmt.Errorf("fetching peertube comments: %w", err) - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("decoding peertube comments: %w", err) + return nil, err } comments := make([]Comment, 0) var walk func(node *peertubeCommentNode, parentRef string) @@ -647,8 +659,8 @@ func (p *PeerTubeAdapter) ListComments(ctx context.Context, accessToken, _ strin walk(&node.Children[i], ref) } } - for i := range result.Data { - node := peertubeCommentNode{Comment: result.Data[i]} + for i := range threads { + node := peertubeCommentNode{Comment: threads[i]} if node.Comment.TotalReplies > 0 { tree, err := p.fetchCommentThread(ctx, accessToken, videoID, node.Comment.ID) if err != nil { diff --git a/apps/server/internal/platform/peertube_test.go b/apps/server/internal/platform/peertube_test.go index 3d043e232..83c58fffe 100644 --- a/apps/server/internal/platform/peertube_test.go +++ b/apps/server/internal/platform/peertube_test.go @@ -153,6 +153,57 @@ func TestPeerTubeLoginAndChannelSelection(t *testing.T) { require.Equal(t, server.URL, selected.InstanceURL) } +func TestPeerTubeChannelSelectionReadsEveryChannelPage(t *testing.T) { + // GET /api/v1/accounts/{name}/video-channels is paginated: without a + // count PeerTube returns 15 channels, and it rejects a count above 100. + const channelCount = 105 + var starts []string + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/users/me", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":42,"username":"rodrigo","account":{"name":"rodrigo","displayName":"Rodrigo"}}`)) + }) + mux.HandleFunc("/api/v1/accounts/rodrigo/video-channels", func(w http.ResponseWriter, r *http.Request) { + start, count := 0, 15 + if value := r.URL.Query().Get("start"); value != "" { + start, _ = strconv.Atoi(value) + } + if value := r.URL.Query().Get("count"); value != "" { + count, _ = strconv.Atoi(value) + } + if count > 100 { + http.Error(w, "Should have a number count (max: 100)", http.StatusBadRequest) + return + } + starts = append(starts, strconv.Itoa(start)) + channels := make([]peertubeChannel, 0, count) + for id := start + 1; id <= min(start+count, channelCount); id++ { + channels = append(channels, peertubeChannel{ID: int64(id), Name: "channel_" + strconv.Itoa(id), DisplayName: "Channel " + strconv.Itoa(id)}) + } + _ = json.NewEncoder(w).Encode(struct { + Total int64 `json:"total"` + Data []peertubeChannel `json:"data"` + }{Total: channelCount, Data: channels}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + adapter := NewPeerTubeAdapter(server.URL) + token := &TokenResult{AccessToken: "atok"} + options, err := adapter.ListAccountSelections(t.Context(), token) + require.NoError(t, err) + require.Len(t, options, channelCount) + require.Equal(t, []string{"0", "100"}, starts, "channels must be read page by page") + require.Equal(t, "channel_105", options[channelCount-1].ID) + + selected, err := adapter.SelectAccount(t.Context(), token, "channel_105") + require.NoError(t, err) + require.Equal(t, "channel_105", selected.AccountID) + + picker, err := adapter.SearchPublishingOptions(t.Context(), "atok", PublishingOptionsInput{Source: "peertube_channels", Search: "Channel 105"}) + require.NoError(t, err) + require.Equal(t, []DestinationOption{{Value: "channel_105", Label: "Channel 105"}}, picker.Options) +} + func TestPeerTubeResumableUploadAndPublish(t *testing.T) { server, fake := newFakePeerTube(t) defer server.Close() @@ -336,6 +387,59 @@ func TestPeerTubeCommentsFetchesTruncatedReplies(t *testing.T) { require.Equal(t, "peertube:video-uuid-1:16", byID["peertube:video-uuid-1:17"].ParentID) } +func TestPeerTubeCommentsReadsEveryThreadPage(t *testing.T) { + // GET /api/v1/videos/{id}/comment-threads is paginated with start and + // count (max 100); total counts threads, newest first. + const threadCount = 130 + var starts []string + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/videos/video-uuid-1/comment-threads", func(w http.ResponseWriter, r *http.Request) { + start, count := 0, 15 + if value := r.URL.Query().Get("start"); value != "" { + start, _ = strconv.Atoi(value) + } + if value := r.URL.Query().Get("count"); value != "" { + count, _ = strconv.Atoi(value) + } + if count > 100 { + http.Error(w, "Should have a number count (max: 100)", http.StatusBadRequest) + return + } + starts = append(starts, strconv.Itoa(start)) + threads := make([]peertubeComment, 0, count) + for index := start; index < min(start+count, threadCount); index++ { + id := int64(threadCount - index) + threads = append(threads, peertubeComment{ID: id, ThreadID: id, Text: "Thread " + strconv.FormatInt(id, 10), Account: peertubeAccount{Name: "viewer"}}) + } + _ = json.NewEncoder(w).Encode(struct { + Total int64 `json:"total"` + Data []peertubeComment `json:"data"` + }{Total: threadCount, Data: threads}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + adapter := NewPeerTubeAdapter(server.URL) + comments, err := adapter.ListComments(t.Context(), "token", "channel", "video-uuid-1") + require.NoError(t, err) + require.Len(t, comments, threadCount) + require.Equal(t, []string{"0", "100"}, starts, "threads must be read page by page") + require.Equal(t, "peertube:video-uuid-1:1", comments[threadCount-1].ID, "the oldest thread is listed") + + for body, want := range map[string]string{"": "listing peertube comments", "not json": "decoding peertube comments"} { + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if body == "" { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte(body)) + })) + _, err := NewPeerTubeAdapter(failing.URL).ListComments(t.Context(), "token", "channel", "video-uuid-1") + failing.Close() + require.ErrorContains(t, err, want) + } +} + func TestPeerTubeCategoryAndLicencePickers(t *testing.T) { // GET /api/v1/videos/categories and /licences answer with a plain // id-to-label object, not a paginated {"data":[...]} envelope. diff --git a/changes/peertube-channel-pages.md b/changes/peertube-channel-pages.md new file mode 100644 index 000000000..9213fb2ee --- /dev/null +++ b/changes/peertube-channel-pages.md @@ -0,0 +1,4 @@ +### Fixed + +- PeerTube accounts with more than 15 channels can connect and publish to every channel; the channel list now reads every page instead of stopping at PeerTube's default page of 15. +- PeerTube videos with more than 100 comment threads bring every thread into the inbox; older threads, and new replies on them, are no longer dropped after the first page.