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

42
app.go
View File

@ -5,7 +5,6 @@ import (
"FanslySync/structs" "FanslySync/structs"
"FanslySync/utils" "FanslySync/utils"
"context" "context"
"fmt"
"github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/logger"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
@ -16,7 +15,6 @@ type App struct {
ctx context.Context ctx context.Context
ConfigManager handlers.ConfigManager ConfigManager handlers.ConfigManager
AppConfig *structs.Config AppConfig *structs.Config
Logger logger.Logger
} }
// NewApp creates a new App application struct // NewApp creates a new App application struct
@ -40,10 +38,20 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) {
} }
// Create our config manager // Create our config manager
a.ConfigManager = handlers.NewFileConfigManager(configPath, logger) configMgr, configMgrCreateErr := handlers.NewFileConfigManager(configPath)
a.Logger = logger
logger.Info("[startup] initializing FanslySync...") if configMgrCreateErr != nil {
// Show message box and quit
utils.ShowMessageBox(a.ctx, "FanslySync | Initialization Error", "Could not create config manager.\n\nError: "+configMgrCreateErr.Error(), utils.WithDialogType(runtime.ErrorDialog))
runtime.Quit(a.ctx)
return
}
// Set the config manager
a.ConfigManager = configMgr
logger.Info("initializing FanslySync...")
// Check our config path to see if it was set correctly. Will not contain FailedConfigPathFetch // Check our config path to see if it was set correctly. Will not contain FailedConfigPathFetch
// Do we have an old config file? // Do we have an old config file?
@ -56,7 +64,7 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) {
} }
if shouldMigrate { if shouldMigrate {
logger.Info("[startup] migrating old config file...") logger.Info("migrating old config file...")
// Migrate the old config file // Migrate the old config file
err := a.ConfigManager.MigrateOldAppConfig() err := a.ConfigManager.MigrateOldAppConfig()
if err != nil { if err != nil {
@ -66,7 +74,7 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) {
return return
} else { } else {
// Show success message // Show success message
logger.Info("[startup] old config file migrate ok") logger.Info("old config file migrate ok")
utils.ShowMessageBox(a.ctx, "FanslySync | Notice", "We've detected an old config file (app version < 2.x and below).\n\nThe old config file has been migrated to the new format for you automatically, and the old config file has been deleted.\n\nPlease check your settings to ensure everything is correct.", utils.WithDialogType(runtime.InfoDialog)) utils.ShowMessageBox(a.ctx, "FanslySync | Notice", "We've detected an old config file (app version < 2.x and below).\n\nThe old config file has been migrated to the new format for you automatically, and the old config file has been deleted.\n\nPlease check your settings to ensure everything is correct.", utils.WithDialogType(runtime.InfoDialog))
// Now grab the new config // Now grab the new config
@ -82,7 +90,7 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) {
} }
} else { } else {
// Load config as normal // Load config as normal
logger.Info("[startup] loading config file...") logger.Info("loading config file...")
cfg, err := a.ConfigManager.LoadConfigOrCreate() cfg, err := a.ConfigManager.LoadConfigOrCreate()
if err != nil { if err != nil {
// Show the error in a message box // Show the error in a message box
@ -91,26 +99,24 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) {
} }
// Set the config // Set the config
logger.Info("[startup] config file loaded ok") logger.Info("config file loaded ok")
a.AppConfig = cfg a.AppConfig = cfg
} }
logger.Info("FanslySync initialized successfully")
} }
// Greet returns a greeting for the given name // Greet returns a greeting for the given name
func (a *App) Greet(token string) string { func (a *App) Greet(token string) string {
// Create fansly API instance // Create fansly API instance
fanslyAPI := handlers.NewFanslyAPIController(token, a.Logger) fanslyAPI, createErr := handlers.NewFanslyAPIController(token)
// Get the user info if createErr != nil {
account, accountErr := fanslyAPI.GetMe() return "Failed to create Fansly API instance: " + createErr.Error()
if accountErr != nil {
return "Failed to get account info: " + accountErr.Error()
} }
// Print the response we got // Sync
a.Logger.Info(fmt.Sprintf("[Greet] Account info: %+v", account)) fanslyAPI.Sync(a.ctx, token, false)
// Return the greeting return "Sync dispatched, check the logs for more info."
return fmt.Sprintf("Hello %s! You have %d fans and %d posts likes.", account.Username, account.FollowCount, account.PostLikes)
} }

View File

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

View File

@ -2,13 +2,17 @@ package handlers
import ( import (
"FanslySync/structs" "FanslySync/structs"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time" "time"
"FanslySync/utils"
"github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/logger"
"github.com/wailsapp/wails/v2/pkg/runtime"
) )
type FanslyAPIController struct { type FanslyAPIController struct {
@ -18,16 +22,24 @@ type FanslyAPIController struct {
} }
// New creates a new Fansly client with the provided token (optional). // 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{ client := &http.Client{
Timeout: 30 * time.Second, 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{ return &FanslyAPIController{
client: client, client: client,
token: token, token: token,
logger: log, logger: apiLogger,
} }, nil
} }
// GET issues a GET to /api/v1/{path}, optionally adding the Auth header, // 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 url := "https://apiv3.fansly.com/api/v1/" + path
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
f.logger.Error("[FanslyAPIController] NewRequest GET " + path + ": " + err.Error()) f.logger.Error("NewRequest GET " + path + ": " + err.Error())
return err return err
} }
@ -53,34 +65,34 @@ func (f *FanslyAPIController) GET(path string, needsAuth bool, out interface{})
// send // send
resp, err := f.client.Do(req) resp, err := f.client.Do(req)
if err != nil { if err != nil {
f.logger.Error("[FanslyAPIController] Do GET " + path + ": " + err.Error()) f.logger.Error("Do GET " + path + ": " + err.Error())
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
// non-200 // non-200
if resp.StatusCode != http.StatusOK { 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 // read body for logs
body, _ := io.ReadAll(resp.Body) 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) return fmt.Errorf("unexpected status %s", resp.Status)
} }
// 200 ok, log // 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 // read body and unmarshal
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { 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 return err
} }
// unmarshal into our expected response type // unmarshal into our expected response type
err = json.Unmarshal(body, &out) err = json.Unmarshal(body, &out)
if err != nil { 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 return err
} }
@ -102,10 +114,154 @@ func (f *FanslyAPIController) GetMe() (*structs.FanslyAccount, error) {
err := f.GET("account/me", true, &response) err := f.GET("account/me", true, &response)
if err != nil { if err != nil {
f.logger.Error("[FanslyAPIController] GetMe failed: " + err.Error()) f.logger.Error("GetMe failed: " + err.Error())
return nil, err return nil, err
} }
// Return the account info // Return the account info
return &response.Response.Account, nil 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)
}

10
main.go
View File

@ -21,9 +21,11 @@ func main() {
app := NewApp() app := NewApp()
// Create our custom file logger // Create our custom file logger
fileLogger, loggerCreateErr := utils.NewRuntimeFileLogger() fileLogger, loggerCreateErr := utils.NewLogger("runtime")
if loggerCreateErr != nil { startupLogger, startupLoggerCreateErr := utils.NewLogger("startup")
log.Fatal(loggerCreateErr)
if loggerCreateErr != nil || startupLoggerCreateErr != nil {
log.Fatal("Failed to create one or more loggers: ", loggerCreateErr, startupLoggerCreateErr)
} }
// Create application with options // Create application with options
@ -40,7 +42,7 @@ func main() {
LogLevelProduction: logger.INFO, LogLevelProduction: logger.INFO,
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: func(ctx context.Context) { OnStartup: func(ctx context.Context) {
app.startup(ctx, fileLogger) app.startup(ctx, startupLogger)
}, },
Bind: []interface{}{ Bind: []interface{}{
app, app,

View File

@ -1,8 +1,9 @@
package structs package structs
type SyncData struct { type SyncData struct {
Followers []FanslyFollowResponse `json:"followers"` // List of followers Followers []string `json:"followers"` // List of followers
Subscriptions []Subscription `json:"subscriptions"` // List of subscriptions Subscriptions []Subscription `json:"subscriptions"` // List of subscriptions
PasteURL string `json:"paste_url"` // URL of the paste
} }
type Config struct { type Config struct {
@ -35,7 +36,7 @@ func NewConfig() *Config {
SyncInterval: 8, SyncInterval: 8,
LastSyncTime: "", LastSyncTime: "",
LastSyncData: SyncData{ LastSyncData: SyncData{
Followers: []FanslyFollowResponse{}, Followers: []string{},
Subscriptions: []Subscription{}, Subscriptions: []Subscription{},
}, },
} }
@ -51,7 +52,7 @@ func NewConfigFromOld(oldConfig *OldConfig) *Config {
SyncInterval: oldConfig.SyncInterval, SyncInterval: oldConfig.SyncInterval,
LastSyncTime: "", LastSyncTime: "",
LastSyncData: SyncData{ LastSyncData: SyncData{
Followers: []FanslyFollowResponse{}, Followers: []string{},
Subscriptions: []Subscription{}, Subscriptions: []Subscription{},
}, },
} }

View File

@ -5,14 +5,17 @@ type FanslyBaseResponse[T any] struct {
Response T `json:"response"` // The response data, type of T Response T `json:"response"` // The response data, type of T
} }
type FanslyBaseResponseAsArray[T any] struct {
Success bool `json:"success"` // Indicates if the request was successful
Response []T `json:"response"` // The response data, type of T
}
type FanslyAccountResponse struct { type FanslyAccountResponse struct {
Account FanslyAccount `json:"account"` Account FanslyAccount `json:"account"`
} }
type FanslyFollowResponse struct { type FanslyFollowResponse struct {
Followers []struct { FollowerID string `json:"followerId"` // The ID of the follower
FollowerID string `json:"followerId"` // The ID of the follower
}
} }
type FanslySubscriptionResponse struct { type FanslySubscriptionResponse struct {

View File

@ -4,7 +4,7 @@ type SyncProgressEvent struct {
// The current step of the sync process. // The current step of the sync process.
Step string `json:"step"` Step string `json:"step"`
// The current percent done of the sync process. // The current percent done of the sync process.
PercentDone int `json:"percent_done"` PercentDone float64 `json:"percent_done"`
// The current count of the current step of the sync process. // The current count of the current step of the sync process.
Count int `json:"current_count"` Count int `json:"current_count"`
// The total count of the current step of the sync process. // The total count of the current step of the sync process.

View File

@ -4,79 +4,40 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/logger"
) )
// multiLogger fans every log call out to multiple logger.Logger targets with timestamps. var (
type multiLogger struct { baseTargets []logger.Logger
once sync.Once
initErr error
)
// InstancedLogger prefixes each message with its instance-specific prefix and a timestamp,
// while writing to shared log outputs.
type InstancedLogger struct {
prefix string
targets []logger.Logger targets []logger.Logger
} }
// timestamped prefixes each message with a timestamp. // NewLogger returns a logger instance that writes to the shared logs
func timestamped(message string) string { //
return time.Now().Format("2006-01-02 15:04:05") + " " + message // but prefixes every message with [prefix].
func NewLogger(prefix string) (*InstancedLogger, error) {
once.Do(func() {
baseTargets, initErr = createBaseTargets()
})
if initErr != nil {
return nil, initErr
}
return &InstancedLogger{prefix: prefix, targets: baseTargets}, nil
} }
func (m *multiLogger) Print(message string) { // createBaseTargets initializes the shared log file and console loggers once.
msg := timestamped(message) func createBaseTargets() ([]logger.Logger, error) {
for _, l := range m.targets {
l.Print(msg)
}
}
func (m *multiLogger) Trace(message string) {
msg := timestamped(message)
for _, l := range m.targets {
l.Trace(msg)
}
}
func (m *multiLogger) Debug(message string) {
msg := timestamped(message)
for _, l := range m.targets {
l.Debug(msg)
}
}
func (m *multiLogger) Info(message string) {
msg := timestamped(message)
for _, l := range m.targets {
l.Info(msg)
}
}
func (m *multiLogger) Warning(message string) {
msg := timestamped(message)
for _, l := range m.targets {
l.Warning(msg)
}
}
func (m *multiLogger) Error(message string) {
msg := timestamped(message)
for _, l := range m.targets {
l.Error(msg)
}
}
func (m *multiLogger) Fatal(message string) {
msg := timestamped(message)
for _, l := range m.targets {
l.Fatal(msg)
}
}
// NewRuntimeFileLogger returns a logger that writes all output both to
//
// $XDG_CONFIG_HOME/FanslySync/logs/runtime_latest.log (or OS equivalent)
//
// and to a timestamped file
//
// $XDG_CONFIG_HOME/FanslySync/logs/runtime_YYYY-MM-DD_HH-MM-SS.log (or OS equivalent)
//
// It also deletes any timestamped logs older than 14 days.
//
// The returned logger implements github.com/wailsapp/wails/v2/pkg/logger.Logger
// and will be used by Wails for all Go-side logging.
func NewRuntimeFileLogger() (logger.Logger, error) {
// Make sure the log directory exists
// We use $XDG_CONFIG_HOME/FanslySync/logs/runtime_latest.log or OS equivalent for $XDG_CONFIG_HOME
cfgDir, err := os.UserConfigDir() cfgDir, err := os.UserConfigDir()
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot determine user config dir: %w", err) return nil, fmt.Errorf("cannot determine user config dir: %w", err)
@ -86,8 +47,7 @@ func NewRuntimeFileLogger() (logger.Logger, error) {
return nil, fmt.Errorf("cannot create log directory: %w", err) return nil, fmt.Errorf("cannot create log directory: %w", err)
} }
// Prune old logs // Prune logs older than 14 days
// We keep logs for 14 days, so delete any logs older than that
cutoff := time.Now().Add(-14 * 24 * time.Hour) cutoff := time.Now().Add(-14 * 24 * time.Hour)
entries, _ := os.ReadDir(logDir) entries, _ := os.ReadDir(logDir)
for _, e := range entries { for _, e := range entries {
@ -103,20 +63,53 @@ func NewRuntimeFileLogger() (logger.Logger, error) {
} }
} }
// Remove old runtime_latest.log if it exists
runtimePath := filepath.Join(logDir, "runtime_latest.log")
if _, err := os.Stat(runtimePath); err == nil {
_ = os.Remove(runtimePath)
}
// Prepare file paths
ts := time.Now().Format("2006-01-02_15-04-05") ts := time.Now().Format("2006-01-02_15-04-05")
tsPath := filepath.Join(logDir, fmt.Sprintf("runtime_%s.log", ts)) tsPath := filepath.Join(logDir, fmt.Sprintf("runtime_%s.log", ts))
latestPath := filepath.Join(logDir, "runtime_latest.log") latestPath := filepath.Join(logDir, "runtime_latest.log")
// Create loggers to attach to the multiLogger // Create loggers
tsLogger := logger.NewFileLogger(tsPath) tsLogger := logger.NewFileLogger(tsPath)
latestLogger := logger.NewFileLogger(latestPath) latestLogger := logger.NewFileLogger(latestPath)
termLogger := logger.NewDefaultLogger() termLogger := logger.NewDefaultLogger()
// Spread into a multiLogger return []logger.Logger{tsLogger, latestLogger, termLogger}, nil
// This will fan out all log messages to all three loggers
multi := &multiLogger{
targets: []logger.Logger{tsLogger, latestLogger, termLogger},
}
return multi, nil
} }
// log dispatches a timestamped, prefixed message to all shared targets.
func (l *InstancedLogger) log(level, message string) {
timestamp := time.Now().Format("2006-01-02 15:04:05")
fullMsg := fmt.Sprintf("%s [%s] %s", timestamp, l.prefix, message)
for _, t := range l.targets {
switch level {
case "Print":
t.Print(fullMsg)
case "Trace":
t.Trace(fullMsg)
case "Debug":
t.Debug(fullMsg)
case "Info":
t.Info(fullMsg)
case "Warning":
t.Warning(fullMsg)
case "Error":
t.Error(fullMsg)
case "Fatal":
t.Fatal(fullMsg)
}
}
}
func (l *InstancedLogger) Print(message string) { l.log("Print", message) }
func (l *InstancedLogger) Trace(message string) { l.log("Trace", message) }
func (l *InstancedLogger) Debug(message string) { l.log("Debug", message) }
func (l *InstancedLogger) Info(message string) { l.log("Info", message) }
func (l *InstancedLogger) Warning(message string) { l.log("Warning", message) }
func (l *InstancedLogger) Error(message string) { l.log("Error", message) }
func (l *InstancedLogger) Fatal(message string) { l.log("Fatal", message) }

View File

@ -1,7 +1,15 @@
package utils package utils
import ( import (
"FanslySync/structs"
"bytes"
"context" "context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
@ -54,3 +62,76 @@ func ShowMessageBox(ctx context.Context, title, message string, opts ...MessageB
// Show the message box // Show the message box
runtime.MessageDialog(ctx, options) runtime.MessageDialog(ctx, options)
} }
func UploadPaste(payload []byte) (string, error) {
// Create HTTP client
client := &http.Client{}
// Max request duration is 30s
client.Timeout = 30 * time.Second
// Create request
var payloadStruct structs.PastePayload
payloadStruct.Content = string(payload)
// Marshal the payload to JSON
payloadJSON, err := json.Marshal(payloadStruct)
if err != nil {
return "", err
}
// Create request (POST to https://paste.hep.gg/api/)
req, err := http.NewRequest("POST", "https://paste.hep.gg/api/", nil)
if err != nil {
return "", err
}
// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "FanslySync/3.0 sticks@teamhydra.dev")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip")
// Set the body
req.Body = io.NopCloser(bytes.NewBuffer(payloadJSON))
// Send the request
resp, err := client.Do(req)
if err != nil {
return "", err
}
// Check for non-200 status code
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// Read the response body into our struct
var pasteResponse structs.PastePutResponse
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// Unmarshal the response into our struct
err = json.Unmarshal(data, &pasteResponse)
if err != nil {
return "", err
}
// Check for error in response
if pasteResponse.Error != "" {
return "", fmt.Errorf("error from paste server: %s", pasteResponse.Error)
}
// Return the paste ID
return fmt.Sprintf("https://paste.hep.gg/api/%s/raw", pasteResponse.Payload.Id), nil
}
// percentage calculates the percentage of curr out of total.
func Percentage(curr, total int) float64 {
if total == 0 {
return 0
}
return float64(curr) / float64(total) * 100
}