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.