sync/handlers/fansly.go
2025-05-20 13:01:02 -04:00

268 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"FanslySync/structs"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"FanslySync/utils"
"github.com/wailsapp/wails/v2/pkg/logger"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type FanslyAPIController struct {
client *http.Client
token string
logger logger.Logger
}
// New creates a new Fansly client with the provided token (optional).
func NewFanslyAPIController(token string) (*FanslyAPIController, error) {
client := &http.Client{
Timeout: 30 * time.Second,
}
apiLogger, apiLoggerCreateErr := utils.NewLogger("FanslyAPIController")
if apiLoggerCreateErr != nil {
// Log the error and return nil
fmt.Println("Failed to create logger: ", apiLoggerCreateErr)
return nil, apiLoggerCreateErr
}
return &FanslyAPIController{
client: client,
token: token,
logger: apiLogger,
}, nil
}
// GET issues a GET to /api/v1/{path}, optionally adding the Auth header,
// and unmarshals the JSON response into the result parameter.
func (f *FanslyAPIController) GET(path string, needsAuth bool, out interface{}) error {
// build request
url := "https://apiv3.fansly.com/api/v1/" + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
f.logger.Error("NewRequest GET " + path + ": " + err.Error())
return err
}
// Set headers
req.Header.Set("User-Agent", "FanslySync/3.0 sticks@teamhydra.dev")
req.Header.Set("Accept", "application/json")
// set auth
if needsAuth && f.token != "" {
req.Header.Set("Authorization", f.token)
}
// send
resp, err := f.client.Do(req)
if err != nil {
f.logger.Error("Do GET " + path + ": " + err.Error())
return err
}
defer resp.Body.Close()
// non-200
if resp.StatusCode != http.StatusOK {
f.logger.Error(fmt.Sprintf("GET %s failed: %s", path, resp.Status))
// read body for logs
body, _ := io.ReadAll(resp.Body)
f.logger.Info("Response body: " + string(body))
return fmt.Errorf("unexpected status %s", resp.Status)
}
// 200 ok, log
f.logger.Debug(fmt.Sprintf("GET %s succeeded: %s", path, resp.Status))
// read body and unmarshal
body, err := io.ReadAll(resp.Body)
if err != nil {
f.logger.Error("Request was OK, but failed to read body: " + err.Error())
return err
}
// unmarshal into our expected response type
err = json.Unmarshal(body, &out)
if err != nil {
f.logger.Error("GET " + path + " failed to unmarshal response: " + err.Error())
return err
}
return nil
}
// SetToken updates the authorization token for subsequent requests.
func (f *FanslyAPIController) SetToken(token string) {
f.token = token
}
// Returns the current user's account information from the Fansly API.
//
// Will error if the token is not set or the request fails.
//
// Returns a FanslyAccount struct containing the account information.
func (f *FanslyAPIController) GetMe() (*structs.FanslyAccount, error) {
var response structs.FanslyBaseResponse[structs.FanslyAccountResponse]
err := f.GET("account/me", true, &response)
if err != nil {
f.logger.Error("GetMe failed: " + err.Error())
return nil, err
}
// Return the account info
return &response.Response.Account, nil
}
func (f *FanslyAPIController) GetFollowersWithOffset(acctId string, offset int) ([]structs.FanslyFollowResponse, error) {
var response structs.FanslyBaseResponseAsArray[structs.FanslyFollowResponse]
err := f.GET(fmt.Sprintf("account/%s/followers?limit=300&offset=%d", acctId, offset), true, &response)
if err != nil {
f.logger.Error("GetFollowersWithOffset failed: " + err.Error())
return nil, err
}
// Return the followers
return response.Response, nil
}
func (f *FanslyAPIController) GetSubscribersWithOffset(offset int) (structs.FanslySubscriptionResponse, error) {
var response structs.FanslyBaseResponse[structs.FanslySubscriptionResponse]
err := f.GET(fmt.Sprintf("subscribers?status=3,4&limit=300&offset=%d", offset), true, &response)
if err != nil {
f.logger.Error("GetSubscribersWithOffset failed: " + err.Error())
return structs.FanslySubscriptionResponse{}, err
}
// Return the subscribers
return response.Response, nil
}
func (f *FanslyAPIController) Sync(ctx context.Context, token string, auto bool) {
// Start the sync worker
go syncWorker(ctx, token, auto)
}
func syncWorker(ctx context.Context, token string, auto bool) {
runtime.EventsEmit(ctx, "sync:started", nil)
progress := func(step string, curr, total int, done bool) {
p := structs.SyncProgressEvent{
Step: step,
PercentDone: utils.Percentage(curr, total),
Count: curr,
TotalCount: total,
Complete: done,
}
runtime.EventsEmit(ctx, "sync:progress", p)
}
// Create a logger dedicated to this sync run
syncLogger, err := utils.NewLogger("SyncWorker")
if err != nil {
runtime.EventsEmit(ctx, "sync:error", err.Error())
return
}
var startTime time.Time = time.Now()
syncLogger.Info(fmt.Sprintf("Starting sync at %s, auto=%t", startTime.Format(time.RFC3339), auto))
syncLogger.Info("[1/4] Fetching profile…")
// Instantiate API controller
c, err := NewFanslyAPIController(token)
if err != nil {
runtime.EventsEmit(ctx, "sync:error", err.Error())
return
}
// 1. Profile -----------------------------------------------------------
progress("Fetching profile", 0, 100, false)
acct, err := c.GetMe()
if err != nil {
runtime.EventsEmit(ctx, "sync:error", err.Error())
return
}
progress("Fetched profile", 0, 100, false)
syncLogger.Info(fmt.Sprintf("Fetched profile. %s %d followers, %d subscribers", acct.Username, acct.FollowCount, acct.SubscriberCount))
// 2. Followers ---------------------------------------------------------
syncLogger.Info("[2/4] Fetching followers…")
followers := make([]string, 0, acct.FollowCount)
offset := 0
for len(followers) < acct.FollowCount {
batch, err := c.GetFollowersWithOffset(acct.ID, offset)
if err != nil {
runtime.EventsEmit(ctx, "sync:error", err.Error())
return
}
for _, f := range batch {
followers = append(followers, f.FollowerID)
}
offset += 300
progress("Fetching followers", len(followers), acct.FollowCount, false)
syncLogger.Info(fmt.Sprintf("[followers] %d/%d (offset=%d)", len(followers), acct.FollowCount, offset))
if len(batch) == 0 || len(followers) >= acct.FollowCount {
break
}
time.Sleep(250 * time.Millisecond) // gentle throttle
}
// 3. Subscribers -------------------------------------------------------
syncLogger.Info("[3/4] Fetching subscribers…")
subs := make([]structs.Subscription, 0, acct.SubscriberCount)
offset = 0
for len(subs) < acct.SubscriberCount {
res, err := c.GetSubscribersWithOffset(offset)
if err != nil {
runtime.EventsEmit(ctx, "sync:error", err.Error())
return
}
subs = append(subs, res.Subscriptions...)
offset += 300
progress("Fetching subscribers", len(subs), acct.SubscriberCount, false)
syncLogger.Info(fmt.Sprintf("[subs] %d/%d (offset=%d)", len(subs), acct.SubscriberCount, offset))
if len(res.Subscriptions) == 0 || len(subs) >= acct.SubscriberCount {
break
}
time.Sleep(250 * time.Millisecond)
}
// 4. Upload & finish ---------------------------------------------------
syncLogger.Info("[4/4] Uploading data…")
data := structs.SyncData{
Followers: followers,
Subscriptions: subs,
}
if !auto {
payload, err := json.Marshal(data)
if err != nil {
runtime.EventsEmit(ctx, "sync:error", err.Error())
return
}
url, err := utils.UploadPaste(payload)
if err != nil {
runtime.EventsEmit(ctx, "sync:error", err.Error())
return
}
data.PasteURL = url
syncLogger.Info(fmt.Sprintf("Uploaded data to %s", url))
}
progress("Sync complete", 100, 100, true)
syncLogger.Info(fmt.Sprintf("Sync done at %s. Took %s, fetched %d followers and %d subscribers", time.Now().Format(time.RFC3339), time.Since(startTime).String(), len(followers), len(subs)))
runtime.EventsEmit(ctx, "sync:complete", data)
}