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) }