268 lines
7.7 KiB
Go
268 lines
7.7 KiB
Go
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)
|
||
}
|