diff --git a/app.go b/app.go index 97918cc..3b0a406 100644 --- a/app.go +++ b/app.go @@ -5,7 +5,6 @@ import ( "FanslySync/structs" "FanslySync/utils" "context" - "fmt" "github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -16,7 +15,6 @@ type App struct { ctx context.Context ConfigManager handlers.ConfigManager AppConfig *structs.Config - Logger logger.Logger } // 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 - a.ConfigManager = handlers.NewFileConfigManager(configPath, logger) - a.Logger = logger + configMgr, configMgrCreateErr := handlers.NewFileConfigManager(configPath) - 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 // Do we have an old config file? @@ -56,7 +64,7 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) { } if shouldMigrate { - logger.Info("[startup] migrating old config file...") + logger.Info("migrating old config file...") // Migrate the old config file err := a.ConfigManager.MigrateOldAppConfig() if err != nil { @@ -66,7 +74,7 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) { return } else { // 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)) // Now grab the new config @@ -82,7 +90,7 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) { } } else { // Load config as normal - logger.Info("[startup] loading config file...") + logger.Info("loading config file...") cfg, err := a.ConfigManager.LoadConfigOrCreate() if err != nil { // Show the error in a message box @@ -91,26 +99,24 @@ func (a *App) startup(ctx context.Context, logger logger.Logger) { } // Set the config - logger.Info("[startup] config file loaded ok") + logger.Info("config file loaded ok") a.AppConfig = cfg } + logger.Info("FanslySync initialized successfully") } // Greet returns a greeting for the given name func (a *App) Greet(token string) string { // Create fansly API instance - fanslyAPI := handlers.NewFanslyAPIController(token, a.Logger) + fanslyAPI, createErr := handlers.NewFanslyAPIController(token) - // Get the user info - account, accountErr := fanslyAPI.GetMe() - if accountErr != nil { - return "Failed to get account info: " + accountErr.Error() + if createErr != nil { + return "Failed to create Fansly API instance: " + createErr.Error() } - // Print the response we got - a.Logger.Info(fmt.Sprintf("[Greet] Account info: %+v", account)) + // Sync + fanslyAPI.Sync(a.ctx, token, false) - // Return the greeting - return fmt.Sprintf("Hello %s! You have %d fans and %d posts likes.", account.Username, account.FollowCount, account.PostLikes) + return "Sync dispatched, check the logs for more info." } diff --git a/handlers/config.go b/handlers/config.go index 67fd90a..59da5e9 100644 --- a/handlers/config.go +++ b/handlers/config.go @@ -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 } diff --git a/handlers/fansly.go b/handlers/fansly.go index 89d1998..72d3ef3 100644 --- a/handlers/fansly.go +++ b/handlers/fansly.go @@ -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) +} diff --git a/main.go b/main.go index ae90087..ca17385 100644 --- a/main.go +++ b/main.go @@ -21,9 +21,11 @@ func main() { app := NewApp() // Create our custom file logger - fileLogger, loggerCreateErr := utils.NewRuntimeFileLogger() - if loggerCreateErr != nil { - log.Fatal(loggerCreateErr) + fileLogger, loggerCreateErr := utils.NewLogger("runtime") + startupLogger, startupLoggerCreateErr := utils.NewLogger("startup") + + if loggerCreateErr != nil || startupLoggerCreateErr != nil { + log.Fatal("Failed to create one or more loggers: ", loggerCreateErr, startupLoggerCreateErr) } // Create application with options @@ -40,7 +42,7 @@ func main() { LogLevelProduction: logger.INFO, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: func(ctx context.Context) { - app.startup(ctx, fileLogger) + app.startup(ctx, startupLogger) }, Bind: []interface{}{ app, diff --git a/structs/config.go b/structs/config.go index 568f54e..bc33066 100644 --- a/structs/config.go +++ b/structs/config.go @@ -1,8 +1,9 @@ package structs type SyncData struct { - Followers []FanslyFollowResponse `json:"followers"` // List of followers - Subscriptions []Subscription `json:"subscriptions"` // List of subscriptions + Followers []string `json:"followers"` // List of followers + Subscriptions []Subscription `json:"subscriptions"` // List of subscriptions + PasteURL string `json:"paste_url"` // URL of the paste } type Config struct { @@ -35,7 +36,7 @@ func NewConfig() *Config { SyncInterval: 8, LastSyncTime: "", LastSyncData: SyncData{ - Followers: []FanslyFollowResponse{}, + Followers: []string{}, Subscriptions: []Subscription{}, }, } @@ -51,7 +52,7 @@ func NewConfigFromOld(oldConfig *OldConfig) *Config { SyncInterval: oldConfig.SyncInterval, LastSyncTime: "", LastSyncData: SyncData{ - Followers: []FanslyFollowResponse{}, + Followers: []string{}, Subscriptions: []Subscription{}, }, } diff --git a/structs/fansly.go b/structs/fansly.go index e72a78f..365e477 100644 --- a/structs/fansly.go +++ b/structs/fansly.go @@ -5,14 +5,17 @@ type FanslyBaseResponse[T any] struct { 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 { Account FanslyAccount `json:"account"` } 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 { diff --git a/structs/sync.go b/structs/sync.go index 9b7f6ae..6c884aa 100644 --- a/structs/sync.go +++ b/structs/sync.go @@ -4,7 +4,7 @@ type SyncProgressEvent struct { // The current step of the sync process. Step string `json:"step"` // 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. Count int `json:"current_count"` // The total count of the current step of the sync process. diff --git a/utils/logger.go b/utils/logger.go index 0499995..161188d 100644 --- a/utils/logger.go +++ b/utils/logger.go @@ -4,79 +4,40 @@ import ( "fmt" "os" "path/filepath" + "sync" "time" "github.com/wailsapp/wails/v2/pkg/logger" ) -// multiLogger fans every log call out to multiple logger.Logger targets with timestamps. -type multiLogger struct { +var ( + 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 } -// timestamped prefixes each message with a timestamp. -func timestamped(message string) string { - return time.Now().Format("2006-01-02 15:04:05") + " " + message +// NewLogger returns a logger instance that writes to the shared logs +// +// 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) { - msg := timestamped(message) - 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 +// createBaseTargets initializes the shared log file and console loggers once. +func createBaseTargets() ([]logger.Logger, error) { cfgDir, err := os.UserConfigDir() if err != nil { 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) } - // Prune old logs - // We keep logs for 14 days, so delete any logs older than that + // Prune logs older than 14 days cutoff := time.Now().Add(-14 * 24 * time.Hour) entries, _ := os.ReadDir(logDir) 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") tsPath := filepath.Join(logDir, fmt.Sprintf("runtime_%s.log", ts)) latestPath := filepath.Join(logDir, "runtime_latest.log") - // Create loggers to attach to the multiLogger + // Create loggers tsLogger := logger.NewFileLogger(tsPath) latestLogger := logger.NewFileLogger(latestPath) termLogger := logger.NewDefaultLogger() - // Spread into a multiLogger - // This will fan out all log messages to all three loggers - multi := &multiLogger{ - targets: []logger.Logger{tsLogger, latestLogger, termLogger}, - } - - return multi, nil + return []logger.Logger{tsLogger, latestLogger, termLogger}, 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) } diff --git a/utils/utils.go b/utils/utils.go index 01e7c87..538d140 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,7 +1,15 @@ package utils import ( + "FanslySync/structs" + "bytes" "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + "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 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 +}