An engagement based washing machine that spins you round and round!
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Updates

Brooke 18d6adcc cfa1a520

+206 -46
+149 -45
cmd/brooke-spin/main.go
··· 57 57 log.Println("Starting fresh - no previous state found") 58 58 } 59 59 60 + // Initialize original avatar (download if not cached, detect changes) 61 + if err := initializeOriginalAvatar(blueskyClient, imageProcessor, stateManager, currentState); err != nil { 62 + log.Fatalf("Failed to initialize original avatar: %v", err) 63 + } 64 + 60 65 // Setup graceful shutdown 61 66 ctx, cancel := context.WithCancel(context.Background()) 62 67 defer cancel() ··· 103 108 stateManager *state.Manager, 104 109 currentState *state.State, 105 110 ) error { 111 + // Check if avatar changed externally before processing notifications 112 + if err := detectAvatarChange(blueskyClient, imageProcessor, stateManager, currentState); err != nil { 113 + log.Printf("Warning: failed to detect avatar change: %v", err) 114 + } 115 + 106 116 // Fetch notifications 107 - log.Printf("Checking for notifications (already processed: %d)", len(currentState.ProcessedNotifications)) 117 + log.Printf("Checking for notifications (processed: %d, cursor: %s)", 118 + len(currentState.ProcessedNotifications), currentState.LastNotificationCursor) 108 119 notifications, newCursor, err := blueskyClient.ListNotifications(currentState.LastNotificationCursor, 50) 109 120 if err != nil { 110 121 return fmt.Errorf("failed to fetch notifications: %w", err) ··· 143 154 144 155 log.Printf("Found %d new relevant notification(s)", len(relevantNotifications)) 145 156 146 - // Process each notification separately 147 - for i, notif := range relevantNotifications { 148 - log.Printf("Processing notification %d/%d (from @%s, type: %s)", i+1, len(relevantNotifications), notif.Author.Handle, notif.Reason) 157 + // Calculate NEW rotation to add to cumulative 158 + newRotation := float64(len(relevantNotifications)) * cfg.Rotation.Degrees 159 + totalRotation := currentState.CumulativeRotation + newRotation 160 + 161 + log.Printf("Rotating from original by %.2f° (was %.2f°, adding %.2f°)", 162 + totalRotation, currentState.CumulativeRotation, newRotation) 163 + 164 + // Load ORIGINAL avatar from disk (NOT from Bluesky!) 165 + log.Println("Loading original avatar from disk...") 166 + avatarData, err := imageProcessor.LoadOriginalAvatar(currentState.OriginalAvatarPath) 167 + if err != nil { 168 + return fmt.Errorf("failed to load original avatar: %w", err) 169 + } 170 + 171 + // Rotate original by TOTAL cumulative rotation 172 + rotatedData, format, err := imageProcessor.RotateImage(avatarData, totalRotation) 173 + if err != nil { 174 + return fmt.Errorf("failed to rotate image: %w", err) 175 + } 176 + 177 + // Upload new avatar 178 + log.Println("Uploading rotated avatar...") 179 + blobRef, err := blueskyClient.UploadBlob(rotatedData, image.GetMimeType(format)) 180 + if err != nil { 181 + return fmt.Errorf("failed to upload blob: %w", err) 182 + } 183 + 184 + // Update profile with new avatar 185 + profile.Avatar = blobRef 186 + log.Println("Updating profile...") 187 + if err := blueskyClient.UpdateProfile(profile); err != nil { 188 + // Mark all as processed even on failure to avoid infinite retry loops 189 + log.Printf("Failed to update profile: %v", err) 190 + for _, notif := range relevantNotifications { 191 + currentState.ProcessedNotifications[notif.URI] = true 192 + } 193 + // Save state to prevent retrying these notifications 194 + if saveErr := stateManager.Save(currentState); saveErr != nil { 195 + log.Printf("Warning: failed to save state after error: %v", saveErr) 196 + } 197 + return fmt.Errorf("failed to update profile: %w", err) 198 + } 199 + 200 + // Update state for all notifications 201 + currentState.CumulativeRotation = totalRotation 202 + for _, notif := range relevantNotifications { 203 + currentState.ProcessedNotifications[notif.URI] = true 204 + } 205 + 206 + log.Printf("✓ Processed %d notification(s)! Total rotation: %.2f°", len(relevantNotifications), currentState.CumulativeRotation) 207 + 208 + // Update state 209 + currentState.LastNotificationCursor = newCursor 210 + currentState.LastProcessedAt = time.Now().Format(time.RFC3339) 211 + 212 + // Normalize cumulative rotation to 0-360 range 213 + currentState.CumulativeRotation = math.Mod(currentState.CumulativeRotation, 360) 214 + if currentState.CumulativeRotation < 0 { 215 + currentState.CumulativeRotation += 360 216 + } 217 + 218 + // Prune old notification entries (keep last 1000) 219 + if len(currentState.ProcessedNotifications) > 1000 { 220 + log.Printf("Pruning processed notifications (was %d, clearing to prevent memory growth)", 221 + len(currentState.ProcessedNotifications)) 222 + currentState.ProcessedNotifications = make(map[string]bool) 223 + } 224 + 225 + if err := stateManager.Save(currentState); err != nil { 226 + return fmt.Errorf("failed to save state: %w", err) 227 + } 228 + 229 + return nil 230 + } 231 + 232 + // initializeOriginalAvatar ensures we have a cached original avatar 233 + func initializeOriginalAvatar( 234 + blueskyClient *client.Client, 235 + imageProcessor *image.Processor, 236 + stateManager *state.Manager, 237 + currentState *state.State, 238 + ) error { 239 + // Check if original avatar file exists 240 + if _, err := os.Stat(currentState.OriginalAvatarPath); os.IsNotExist(err) { 241 + log.Println("No cached original avatar found, downloading...") 149 242 150 243 // Get current profile 151 244 profile, err := blueskyClient.GetProfile() ··· 159 252 return fmt.Errorf("failed to get avatar URL: %w", err) 160 253 } 161 254 162 - // Download current avatar 163 - log.Println("Downloading current avatar...") 255 + // Download avatar 164 256 avatarData, err := imageProcessor.DownloadImage(avatarURL) 165 257 if err != nil { 166 258 return fmt.Errorf("failed to download avatar: %w", err) 167 259 } 168 260 169 - // Rotate image 170 - log.Printf("Rotating avatar by %.2f degrees...", cfg.Rotation.Degrees) 171 - rotatedData, format, err := imageProcessor.RotateImage(avatarData, cfg.Rotation.Degrees) 172 - if err != nil { 173 - return fmt.Errorf("failed to rotate image: %w", err) 261 + // Save as original 262 + if err := imageProcessor.SaveOriginalAvatar(avatarData, currentState.OriginalAvatarPath); err != nil { 263 + return fmt.Errorf("failed to save original avatar: %w", err) 174 264 } 175 265 176 - // Upload new avatar 177 - log.Println("Uploading rotated avatar...") 178 - blobRef, err := blueskyClient.UploadBlob(rotatedData, image.GetMimeType(format)) 179 - if err != nil { 180 - return fmt.Errorf("failed to upload blob: %w", err) 266 + // Store CID in state 267 + currentState.OriginalAvatarCID = profile.Avatar.Ref.Link 268 + if err := stateManager.Save(currentState); err != nil { 269 + return fmt.Errorf("failed to save state: %w", err) 181 270 } 182 271 183 - // Update profile with new avatar 184 - profile.Avatar = blobRef 185 - log.Println("Updating profile...") 186 - if err := blueskyClient.UpdateProfile(profile); err != nil { 187 - // Mark as processed even on failure to avoid infinite retry loops 188 - log.Printf("Failed to update profile: %v", err) 189 - currentState.ProcessedNotifications[notif.URI] = true 190 - // Save state to prevent retrying this notification 191 - if saveErr := stateManager.Save(currentState); saveErr != nil { 192 - log.Printf("Warning: failed to save state after error: %v", saveErr) 193 - } 194 - return fmt.Errorf("failed to update profile: %w", err) 195 - } 272 + log.Printf("Original avatar cached: CID=%s", currentState.OriginalAvatarCID) 273 + } 196 274 197 - // Update state for this notification 198 - currentState.CumulativeRotation += cfg.Rotation.Degrees 199 - currentState.ProcessedNotifications[notif.URI] = true 275 + // Verify CID hasn't changed (user manually changed avatar) 276 + return detectAvatarChange(blueskyClient, imageProcessor, stateManager, currentState) 277 + } 200 278 201 - log.Printf("✓ Notification %d/%d processed! (Total rotation: %.2f°)", i+1, len(relevantNotifications), currentState.CumulativeRotation) 279 + // detectAvatarChange checks if user manually changed their profile picture 280 + func detectAvatarChange( 281 + blueskyClient *client.Client, 282 + imageProcessor *image.Processor, 283 + stateManager *state.Manager, 284 + currentState *state.State, 285 + ) error { 286 + profile, err := blueskyClient.GetProfile() 287 + if err != nil { 288 + return fmt.Errorf("failed to get profile: %w", err) 202 289 } 203 290 204 - // Update state 205 - currentState.LastNotificationCursor = newCursor 206 - currentState.LastProcessedAt = time.Now().Format(time.RFC3339) 291 + currentCID := profile.Avatar.Ref.Link 207 292 208 - // Normalize cumulative rotation to 0-360 range 209 - currentState.CumulativeRotation = math.Mod(currentState.CumulativeRotation, 360) 210 - if currentState.CumulativeRotation < 0 { 211 - currentState.CumulativeRotation += 360 212 - } 293 + if currentCID != currentState.OriginalAvatarCID { 294 + log.Printf("⚠ Profile picture changed externally!") 295 + log.Printf(" Old CID: %s", currentState.OriginalAvatarCID) 296 + log.Printf(" New CID: %s", currentCID) 213 297 214 - if err := stateManager.Save(currentState); err != nil { 215 - return fmt.Errorf("failed to save state: %w", err) 216 - } 298 + // Download new avatar as new original 299 + avatarURL, err := blueskyClient.GetAvatarURL(profile) 300 + if err != nil { 301 + return fmt.Errorf("failed to get avatar URL: %w", err) 302 + } 217 303 218 - log.Printf("✓ All %d notification(s) processed! (Total rotation: %.2f°)", len(relevantNotifications), currentState.CumulativeRotation) 304 + avatarData, err := imageProcessor.DownloadImage(avatarURL) 305 + if err != nil { 306 + return fmt.Errorf("failed to download new avatar: %w", err) 307 + } 308 + 309 + // Save as new original 310 + if err := imageProcessor.SaveOriginalAvatar(avatarData, currentState.OriginalAvatarPath); err != nil { 311 + return fmt.Errorf("failed to save new original: %w", err) 312 + } 313 + 314 + // Reset state 315 + currentState.OriginalAvatarCID = currentCID 316 + currentState.CumulativeRotation = 0.0 317 + if err := stateManager.Save(currentState); err != nil { 318 + return fmt.Errorf("failed to save state: %w", err) 319 + } 320 + 321 + log.Println("✓ Updated to new original avatar, rotation reset to 0°") 322 + } 219 323 220 324 return nil 221 325 }
+13 -1
internal/image/processor.go
··· 8 8 "image/jpeg" 9 9 "io" 10 10 "net/http" 11 + "os" 11 12 12 13 "github.com/disintegration/imaging" 13 14 ) ··· 104 105 105 106 // Always encode as JPEG with compression to keep file size under Bluesky's limit (976.56KB) 106 107 // PNG with transparency can be too large 108 + // Using 95% quality since we only encode once per batch (not repeatedly) 107 109 var buf bytes.Buffer 108 - if err := jpeg.Encode(&buf, masked, &jpeg.Options{Quality: 85}); err != nil { 110 + if err := jpeg.Encode(&buf, masked, &jpeg.Options{Quality: 95}); err != nil { 109 111 return nil, "", fmt.Errorf("failed to encode jpeg: %w", err) 110 112 } 111 113 112 114 return buf.Bytes(), "jpeg", nil 115 + } 116 + 117 + // SaveOriginalAvatar saves the original avatar to disk for quality preservation 118 + func (p *Processor) SaveOriginalAvatar(data []byte, path string) error { 119 + return os.WriteFile(path, data, 0644) 120 + } 121 + 122 + // LoadOriginalAvatar loads the original avatar from disk 123 + func (p *Processor) LoadOriginalAvatar(path string) ([]byte, error) { 124 + return os.ReadFile(path) 113 125 } 114 126 115 127 // GetMimeType returns the MIME type for an image format
+44
internal/state/manager.go
··· 25 25 LastProcessedAt string `json:"last_processed_at"` 26 26 CumulativeRotation float64 `json:"cumulative_rotation"` 27 27 ProcessedNotifications map[string]bool `json:"processed_notifications"` // Track URIs we've processed 28 + 29 + // Original avatar tracking for quality preservation 30 + OriginalAvatarCID string `json:"original_avatar_cid"` // Blob CID of cached original 31 + OriginalAvatarPath string `json:"original_avatar_path"` // Local file path 28 32 } 29 33 30 34 // NewManager creates a new state manager ··· 134 138 } 135 139 } 136 140 141 + // Load original avatar tracking fields 142 + row = m.db.QueryRow("SELECT value FROM state WHERE key = ?", "original_avatar_cid") 143 + var originalCID string 144 + if err := row.Scan(&originalCID); err != nil && err != sql.ErrNoRows { 145 + return nil, fmt.Errorf("failed to load original_avatar_cid: %w", err) 146 + } 147 + state.OriginalAvatarCID = originalCID 148 + 149 + row = m.db.QueryRow("SELECT value FROM state WHERE key = ?", "original_avatar_path") 150 + var originalPath string 151 + if err := row.Scan(&originalPath); err != nil && err != sql.ErrNoRows { 152 + return nil, fmt.Errorf("failed to load original_avatar_path: %w", err) 153 + } 154 + state.OriginalAvatarPath = originalPath 155 + 156 + // Set default path if not set 157 + if state.OriginalAvatarPath == "" { 158 + state.OriginalAvatarPath = "/data/original-avatar.jpg" 159 + } 160 + 137 161 return state, nil 138 162 } 139 163 ··· 178 202 return fmt.Errorf("failed to save processed_notifications: %w", err) 179 203 } 180 204 205 + // Save original avatar tracking fields 206 + _, err = tx.Exec(` 207 + INSERT OR REPLACE INTO state (key, value) VALUES (?, ?) 208 + `, "original_avatar_cid", state.OriginalAvatarCID) 209 + if err != nil { 210 + return fmt.Errorf("failed to save original_avatar_cid: %w", err) 211 + } 212 + 213 + _, err = tx.Exec(` 214 + INSERT OR REPLACE INTO state (key, value) VALUES (?, ?) 215 + `, "original_avatar_path", state.OriginalAvatarPath) 216 + if err != nil { 217 + return fmt.Errorf("failed to save original_avatar_path: %w", err) 218 + } 219 + 181 220 return tx.Commit() 182 221 } 183 222 ··· 201 240 // Initialize map if nil 202 241 if state.ProcessedNotifications == nil { 203 242 state.ProcessedNotifications = make(map[string]bool) 243 + } 244 + 245 + // Set default path if not set 246 + if state.OriginalAvatarPath == "" { 247 + state.OriginalAvatarPath = "/data/original-avatar.jpg" 204 248 } 205 249 206 250 return &state, nil