manul sync backend works now

This commit is contained in:
Sticks
2025-05-20 13:01:02 -04:00
parent 7aa2dee280
commit 96abb94f21
9 changed files with 397 additions and 147 deletions

View File

@ -7,6 +7,7 @@ import (
"path/filepath"
"FanslySync/structs"
"FanslySync/utils"
"github.com/wailsapp/wails/v2/pkg/logger"
)
@ -43,11 +44,18 @@ type FileConfigManager struct {
log logger.Logger
}
func NewFileConfigManager(path string, log logger.Logger) ConfigManager {
func NewFileConfigManager(path string) (ConfigManager, error) {
// Create our logger
fileLogger, loggerCreateErr := utils.NewLogger("ConfigManager")
if loggerCreateErr != nil {
// Log the error and return nil
return nil, loggerCreateErr
}
return &FileConfigManager{
path: path,
log: log,
}
log: fileLogger,
}, nil
}
// GetConfigPath returns the path to the config file.
@ -65,96 +73,96 @@ func GetConfigPathForRuntime() (string, error) {
// ShouldMigrateOldAppConfig checks for an existing legacy config.json and logs the result.
func (mgr *FileConfigManager) ShouldMigrateOldAppConfig() (bool, error) {
mgr.log.Info("[ConfigManager::ShouldMigrateOldAppConfig] Checking for old config file")
mgr.log.Info("Checking for old config file")
dir, err := os.UserConfigDir()
if err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::ShouldMigrateOldAppConfig] Error getting user config dir: %v", err))
mgr.log.Error(fmt.Sprintf("Error getting user config dir: %v", err))
return false, err
}
oldConfigPath := filepath.Join(dir, "FanslySync", "config.json")
if _, err := os.Stat(oldConfigPath); os.IsNotExist(err) {
mgr.log.Info(fmt.Sprintf("[ConfigManager::ShouldMigrateOldAppConfig] No old config at %s", oldConfigPath))
mgr.log.Info(fmt.Sprintf("No old config at %s", oldConfigPath))
return false, nil
} else if err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::ShouldMigrateOldAppConfig] Error checking old config: %v", err))
mgr.log.Error(fmt.Sprintf("Error checking old config: %v", err))
return false, err
}
mgr.log.Info(fmt.Sprintf("[ConfigManager::ShouldMigrateOldAppConfig] Old config exists at %s", oldConfigPath))
mgr.log.Info(fmt.Sprintf("Old config exists at %s", oldConfigPath))
return true, nil
}
// MigrateOldAppConfig reads the legacy config.json, converts it, saves the new format,
// and removes the old file, logging each step.
func (mgr *FileConfigManager) MigrateOldAppConfig() error {
mgr.log.Info("[ConfigManager::MigrateOldAppConfig] Migrating old config file")
mgr.log.Info("Migrating old config file")
dir, err := os.UserConfigDir()
if err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::MigrateOldAppConfig] Error getting user config dir: %v", err))
mgr.log.Error(fmt.Sprintf("Error getting user config dir: %v", err))
return err
}
oldConfigPath := filepath.Join(dir, "FanslySync", "config.json")
if _, err := os.Stat(oldConfigPath); os.IsNotExist(err) {
mgr.log.Info("[ConfigManager::MigrateOldAppConfig] No old config to migrate")
mgr.log.Info("No old config to migrate")
return nil
} else if err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::MigrateOldAppConfig] Error checking old config: %v", err))
mgr.log.Error(fmt.Sprintf("Error checking old config: %v", err))
return err
}
data, err := os.ReadFile(oldConfigPath)
if err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::MigrateOldAppConfig] Error reading old config: %v", err))
mgr.log.Error(fmt.Sprintf("Error reading old config: %v", err))
return err
}
var oldCfg structs.OldConfig
if err := json.Unmarshal(data, &oldCfg); err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::MigrateOldAppConfig] Error unmarshaling old config: %v", err))
mgr.log.Error(fmt.Sprintf("Error unmarshaling old config: %v", err))
return err
}
newCfg := structs.NewConfigFromOld(&oldCfg)
if err := mgr.SaveConfig(newCfg); err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::MigrateOldAppConfig] Error saving new config: %v", err))
mgr.log.Error(fmt.Sprintf("Error saving new config: %v", err))
return err
}
if err := os.Remove(oldConfigPath); err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::MigrateOldAppConfig] Error removing old config: %v", err))
mgr.log.Error(fmt.Sprintf("Error removing old config: %v", err))
return fmt.Errorf("could not remove old config file: %w", err)
}
mgr.log.Info("[ConfigManager::MigrateOldAppConfig] Migration complete; old config removed")
mgr.log.Info("Migration complete; old config removed")
return nil
}
// GetConfig loads the config from disk if forceReload is true or no cache exists.
// It logs each step and errors encountered.
func (mgr *FileConfigManager) GetConfig(forceReload bool) (*structs.Config, error) {
mgr.log.Debug(fmt.Sprintf("[ConfigManager] GetConfig(forceReload=%v)", forceReload))
mgr.log.Debug(fmt.Sprintf("GetConfig(forceReload=%v)", forceReload))
if mgr.config != nil && !forceReload {
mgr.log.Debug("[ConfigManager::GetConfig] Returning cached config")
mgr.log.Debug("Returning cached config")
return mgr.config, nil
}
data, err := os.ReadFile(mgr.path)
if err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::GetConfig] Error reading config file: %v", err))
mgr.log.Error(fmt.Sprintf("Error reading config file: %v", err))
return nil, err
}
var cfg structs.Config
if err := json.Unmarshal(data, &cfg); err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::GetConfig] Error unmarshaling config: %v", err))
mgr.log.Error(fmt.Sprintf("Error unmarshaling config: %v", err))
return nil, err
}
mgr.config = &cfg
mgr.log.Info("[ConfigManager::GetConfig] Config loaded from disk. Cache updated.")
mgr.log.Debug(fmt.Sprintf("[ConfigManager::GetConfig] Config: %+v", cfg))
mgr.log.Info("Config loaded from disk. Cache updated.")
mgr.log.Debug(fmt.Sprintf("Config: %+v", cfg))
return mgr.config, nil
}
@ -162,44 +170,44 @@ func (mgr *FileConfigManager) GetConfig(forceReload bool) (*structs.Config, erro
func (mgr *FileConfigManager) LoadConfigOrCreate() (*structs.Config, error) {
cfg, err := mgr.GetConfig(false)
if err == nil {
mgr.log.Info("[ConfigManager::LoadConfigOrCreate] Existing config loaded")
mgr.log.Info("Existing config loaded")
return cfg, nil
}
if os.IsNotExist(err) {
mgr.log.Warning("[ConfigManager::LoadConfigOrCreate] Config missing; creating default")
mgr.log.Warning("Config missing; creating default")
defaultCfg := structs.NewConfig()
if saveErr := mgr.SaveConfig(defaultCfg); saveErr != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::LoadConfigOrCreate] Error saving default config: %v", saveErr))
mgr.log.Error(fmt.Sprintf("Error saving default config: %v", saveErr))
return nil, saveErr
}
mgr.log.Info("[ConfigManager::LoadConfigOrCreate] Default config created and saved")
mgr.log.Info("Default config created and saved")
return defaultCfg, nil
}
mgr.log.Error(fmt.Sprintf("[ConfigManager::LoadConfigOrCreate] Error loading config: %v", err))
mgr.log.Error(fmt.Sprintf("Error loading config: %v", err))
return nil, err
}
// SaveConfig writes the config to disk, updates cache, and logs the process.
func (mgr *FileConfigManager) SaveConfig(cfg *structs.Config) error {
mgr.log.Info(fmt.Sprintf("[ConfigManager::SaveConfig] Saving config to %s", mgr.path))
mgr.log.Info(fmt.Sprintf("Saving config to %s", mgr.path))
dir := filepath.Dir(mgr.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::SaveConfig] Error creating config directory: %v", err))
mgr.log.Error(fmt.Sprintf("Error creating config directory: %v", err))
return fmt.Errorf("could not create config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::SaveConfig] Error marshaling config: %v", err))
mgr.log.Error(fmt.Sprintf("Error marshaling config: %v", err))
return fmt.Errorf("could not marshal config: %w", err)
}
if err := os.WriteFile(mgr.path, data, 0o644); err != nil {
mgr.log.Error(fmt.Sprintf("[ConfigManager::SaveConfig] Error writing config file: %v", err))
mgr.log.Error(fmt.Sprintf("Error writing config file: %v", err))
return fmt.Errorf("could not write config file: %w", err)
}
mgr.config = cfg
mgr.log.Info("[ConfigManager::SaveConfig] Config saved and cache updated")
mgr.log.Info("Config saved and cache updated")
return nil
}

View File

@ -2,13 +2,17 @@ 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 {
@ -18,16 +22,24 @@ type FanslyAPIController struct {
}
// New creates a new Fansly client with the provided token (optional).
func NewFanslyAPIController(token string, log logger.Logger) *FanslyAPIController {
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: log,
}
logger: apiLogger,
}, nil
}
// GET issues a GET to /api/v1/{path}, optionally adding the Auth header,
@ -37,7 +49,7 @@ func (f *FanslyAPIController) GET(path string, needsAuth bool, out interface{})
url := "https://apiv3.fansly.com/api/v1/" + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
f.logger.Error("[FanslyAPIController] NewRequest GET " + path + ": " + err.Error())
f.logger.Error("NewRequest GET " + path + ": " + err.Error())
return err
}
@ -53,34 +65,34 @@ func (f *FanslyAPIController) GET(path string, needsAuth bool, out interface{})
// send
resp, err := f.client.Do(req)
if err != nil {
f.logger.Error("[FanslyAPIController] Do GET " + path + ": " + err.Error())
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("[FanslyAPIController] GET %s failed: %s", path, resp.Status))
f.logger.Error(fmt.Sprintf("GET %s failed: %s", path, resp.Status))
// read body for logs
body, _ := io.ReadAll(resp.Body)
f.logger.Info("[FanslyAPIController] Response body: " + string(body))
f.logger.Info("Response body: " + string(body))
return fmt.Errorf("unexpected status %s", resp.Status)
}
// 200 ok, log
f.logger.Debug(fmt.Sprintf("[FanslyAPIController] GET %s succeeded: %s", path, resp.Status))
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("[FanslyAPIController] Request was OK, but failed to read body: " + err.Error())
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("[FanslyAPIController] GET " + path + " failed to unmarshal response: " + err.Error())
f.logger.Error("GET " + path + " failed to unmarshal response: " + err.Error())
return err
}
@ -102,10 +114,154 @@ func (f *FanslyAPIController) GetMe() (*structs.FanslyAccount, error) {
err := f.GET("account/me", true, &response)
if err != nil {
f.logger.Error("[FanslyAPIController] GetMe failed: " + err.Error())
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)
}