Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 38 additions & 26 deletions apps/server/internal/platform/peertube.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions apps/server/internal/platform/peertube_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions changes/peertube-channel-pages.md
Original file line number Diff line number Diff line change
@@ -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.
Loading