···5757 log.Println("Starting fresh - no previous state found")
5858 }
59596060+ // Initialize original avatar (download if not cached, detect changes)
6161+ if err := initializeOriginalAvatar(blueskyClient, imageProcessor, stateManager, currentState); err != nil {
6262+ log.Fatalf("Failed to initialize original avatar: %v", err)
6363+ }
6464+6065 // Setup graceful shutdown
6166 ctx, cancel := context.WithCancel(context.Background())
6267 defer cancel()
···103108 stateManager *state.Manager,
104109 currentState *state.State,
105110) error {
111111+ // Check if avatar changed externally before processing notifications
112112+ if err := detectAvatarChange(blueskyClient, imageProcessor, stateManager, currentState); err != nil {
113113+ log.Printf("Warning: failed to detect avatar change: %v", err)
114114+ }
115115+106116 // Fetch notifications
107107- log.Printf("Checking for notifications (already processed: %d)", len(currentState.ProcessedNotifications))
117117+ log.Printf("Checking for notifications (processed: %d, cursor: %s)",
118118+ len(currentState.ProcessedNotifications), currentState.LastNotificationCursor)
108119 notifications, newCursor, err := blueskyClient.ListNotifications(currentState.LastNotificationCursor, 50)
109120 if err != nil {
110121 return fmt.Errorf("failed to fetch notifications: %w", err)
···143154144155 log.Printf("Found %d new relevant notification(s)", len(relevantNotifications))
145156146146- // Process each notification separately
147147- for i, notif := range relevantNotifications {
148148- log.Printf("Processing notification %d/%d (from @%s, type: %s)", i+1, len(relevantNotifications), notif.Author.Handle, notif.Reason)
157157+ // Calculate NEW rotation to add to cumulative
158158+ newRotation := float64(len(relevantNotifications)) * cfg.Rotation.Degrees
159159+ totalRotation := currentState.CumulativeRotation + newRotation
160160+161161+ log.Printf("Rotating from original by %.2f° (was %.2f°, adding %.2f°)",
162162+ totalRotation, currentState.CumulativeRotation, newRotation)
163163+164164+ // Load ORIGINAL avatar from disk (NOT from Bluesky!)
165165+ log.Println("Loading original avatar from disk...")
166166+ avatarData, err := imageProcessor.LoadOriginalAvatar(currentState.OriginalAvatarPath)
167167+ if err != nil {
168168+ return fmt.Errorf("failed to load original avatar: %w", err)
169169+ }
170170+171171+ // Rotate original by TOTAL cumulative rotation
172172+ rotatedData, format, err := imageProcessor.RotateImage(avatarData, totalRotation)
173173+ if err != nil {
174174+ return fmt.Errorf("failed to rotate image: %w", err)
175175+ }
176176+177177+ // Upload new avatar
178178+ log.Println("Uploading rotated avatar...")
179179+ blobRef, err := blueskyClient.UploadBlob(rotatedData, image.GetMimeType(format))
180180+ if err != nil {
181181+ return fmt.Errorf("failed to upload blob: %w", err)
182182+ }
183183+184184+ // Update profile with new avatar
185185+ profile.Avatar = blobRef
186186+ log.Println("Updating profile...")
187187+ if err := blueskyClient.UpdateProfile(profile); err != nil {
188188+ // Mark all as processed even on failure to avoid infinite retry loops
189189+ log.Printf("Failed to update profile: %v", err)
190190+ for _, notif := range relevantNotifications {
191191+ currentState.ProcessedNotifications[notif.URI] = true
192192+ }
193193+ // Save state to prevent retrying these notifications
194194+ if saveErr := stateManager.Save(currentState); saveErr != nil {
195195+ log.Printf("Warning: failed to save state after error: %v", saveErr)
196196+ }
197197+ return fmt.Errorf("failed to update profile: %w", err)
198198+ }
199199+200200+ // Update state for all notifications
201201+ currentState.CumulativeRotation = totalRotation
202202+ for _, notif := range relevantNotifications {
203203+ currentState.ProcessedNotifications[notif.URI] = true
204204+ }
205205+206206+ log.Printf("✓ Processed %d notification(s)! Total rotation: %.2f°", len(relevantNotifications), currentState.CumulativeRotation)
207207+208208+ // Update state
209209+ currentState.LastNotificationCursor = newCursor
210210+ currentState.LastProcessedAt = time.Now().Format(time.RFC3339)
211211+212212+ // Normalize cumulative rotation to 0-360 range
213213+ currentState.CumulativeRotation = math.Mod(currentState.CumulativeRotation, 360)
214214+ if currentState.CumulativeRotation < 0 {
215215+ currentState.CumulativeRotation += 360
216216+ }
217217+218218+ // Prune old notification entries (keep last 1000)
219219+ if len(currentState.ProcessedNotifications) > 1000 {
220220+ log.Printf("Pruning processed notifications (was %d, clearing to prevent memory growth)",
221221+ len(currentState.ProcessedNotifications))
222222+ currentState.ProcessedNotifications = make(map[string]bool)
223223+ }
224224+225225+ if err := stateManager.Save(currentState); err != nil {
226226+ return fmt.Errorf("failed to save state: %w", err)
227227+ }
228228+229229+ return nil
230230+}
231231+232232+// initializeOriginalAvatar ensures we have a cached original avatar
233233+func initializeOriginalAvatar(
234234+ blueskyClient *client.Client,
235235+ imageProcessor *image.Processor,
236236+ stateManager *state.Manager,
237237+ currentState *state.State,
238238+) error {
239239+ // Check if original avatar file exists
240240+ if _, err := os.Stat(currentState.OriginalAvatarPath); os.IsNotExist(err) {
241241+ log.Println("No cached original avatar found, downloading...")
149242150243 // Get current profile
151244 profile, err := blueskyClient.GetProfile()
···159252 return fmt.Errorf("failed to get avatar URL: %w", err)
160253 }
161254162162- // Download current avatar
163163- log.Println("Downloading current avatar...")
255255+ // Download avatar
164256 avatarData, err := imageProcessor.DownloadImage(avatarURL)
165257 if err != nil {
166258 return fmt.Errorf("failed to download avatar: %w", err)
167259 }
168260169169- // Rotate image
170170- log.Printf("Rotating avatar by %.2f degrees...", cfg.Rotation.Degrees)
171171- rotatedData, format, err := imageProcessor.RotateImage(avatarData, cfg.Rotation.Degrees)
172172- if err != nil {
173173- return fmt.Errorf("failed to rotate image: %w", err)
261261+ // Save as original
262262+ if err := imageProcessor.SaveOriginalAvatar(avatarData, currentState.OriginalAvatarPath); err != nil {
263263+ return fmt.Errorf("failed to save original avatar: %w", err)
174264 }
175265176176- // Upload new avatar
177177- log.Println("Uploading rotated avatar...")
178178- blobRef, err := blueskyClient.UploadBlob(rotatedData, image.GetMimeType(format))
179179- if err != nil {
180180- return fmt.Errorf("failed to upload blob: %w", err)
266266+ // Store CID in state
267267+ currentState.OriginalAvatarCID = profile.Avatar.Ref.Link
268268+ if err := stateManager.Save(currentState); err != nil {
269269+ return fmt.Errorf("failed to save state: %w", err)
181270 }
182271183183- // Update profile with new avatar
184184- profile.Avatar = blobRef
185185- log.Println("Updating profile...")
186186- if err := blueskyClient.UpdateProfile(profile); err != nil {
187187- // Mark as processed even on failure to avoid infinite retry loops
188188- log.Printf("Failed to update profile: %v", err)
189189- currentState.ProcessedNotifications[notif.URI] = true
190190- // Save state to prevent retrying this notification
191191- if saveErr := stateManager.Save(currentState); saveErr != nil {
192192- log.Printf("Warning: failed to save state after error: %v", saveErr)
193193- }
194194- return fmt.Errorf("failed to update profile: %w", err)
195195- }
272272+ log.Printf("Original avatar cached: CID=%s", currentState.OriginalAvatarCID)
273273+ }
196274197197- // Update state for this notification
198198- currentState.CumulativeRotation += cfg.Rotation.Degrees
199199- currentState.ProcessedNotifications[notif.URI] = true
275275+ // Verify CID hasn't changed (user manually changed avatar)
276276+ return detectAvatarChange(blueskyClient, imageProcessor, stateManager, currentState)
277277+}
200278201201- log.Printf("✓ Notification %d/%d processed! (Total rotation: %.2f°)", i+1, len(relevantNotifications), currentState.CumulativeRotation)
279279+// detectAvatarChange checks if user manually changed their profile picture
280280+func detectAvatarChange(
281281+ blueskyClient *client.Client,
282282+ imageProcessor *image.Processor,
283283+ stateManager *state.Manager,
284284+ currentState *state.State,
285285+) error {
286286+ profile, err := blueskyClient.GetProfile()
287287+ if err != nil {
288288+ return fmt.Errorf("failed to get profile: %w", err)
202289 }
203290204204- // Update state
205205- currentState.LastNotificationCursor = newCursor
206206- currentState.LastProcessedAt = time.Now().Format(time.RFC3339)
291291+ currentCID := profile.Avatar.Ref.Link
207292208208- // Normalize cumulative rotation to 0-360 range
209209- currentState.CumulativeRotation = math.Mod(currentState.CumulativeRotation, 360)
210210- if currentState.CumulativeRotation < 0 {
211211- currentState.CumulativeRotation += 360
212212- }
293293+ if currentCID != currentState.OriginalAvatarCID {
294294+ log.Printf("⚠ Profile picture changed externally!")
295295+ log.Printf(" Old CID: %s", currentState.OriginalAvatarCID)
296296+ log.Printf(" New CID: %s", currentCID)
213297214214- if err := stateManager.Save(currentState); err != nil {
215215- return fmt.Errorf("failed to save state: %w", err)
216216- }
298298+ // Download new avatar as new original
299299+ avatarURL, err := blueskyClient.GetAvatarURL(profile)
300300+ if err != nil {
301301+ return fmt.Errorf("failed to get avatar URL: %w", err)
302302+ }
217303218218- log.Printf("✓ All %d notification(s) processed! (Total rotation: %.2f°)", len(relevantNotifications), currentState.CumulativeRotation)
304304+ avatarData, err := imageProcessor.DownloadImage(avatarURL)
305305+ if err != nil {
306306+ return fmt.Errorf("failed to download new avatar: %w", err)
307307+ }
308308+309309+ // Save as new original
310310+ if err := imageProcessor.SaveOriginalAvatar(avatarData, currentState.OriginalAvatarPath); err != nil {
311311+ return fmt.Errorf("failed to save new original: %w", err)
312312+ }
313313+314314+ // Reset state
315315+ currentState.OriginalAvatarCID = currentCID
316316+ currentState.CumulativeRotation = 0.0
317317+ if err := stateManager.Save(currentState); err != nil {
318318+ return fmt.Errorf("failed to save state: %w", err)
319319+ }
320320+321321+ log.Println("✓ Updated to new original avatar, rotation reset to 0°")
322322+ }
219323220324 return nil
221325}
+13-1
internal/image/processor.go
···88 "image/jpeg"
99 "io"
1010 "net/http"
1111+ "os"
11121213 "github.com/disintegration/imaging"
1314)
···104105105106 // Always encode as JPEG with compression to keep file size under Bluesky's limit (976.56KB)
106107 // PNG with transparency can be too large
108108+ // Using 95% quality since we only encode once per batch (not repeatedly)
107109 var buf bytes.Buffer
108108- if err := jpeg.Encode(&buf, masked, &jpeg.Options{Quality: 85}); err != nil {
110110+ if err := jpeg.Encode(&buf, masked, &jpeg.Options{Quality: 95}); err != nil {
109111 return nil, "", fmt.Errorf("failed to encode jpeg: %w", err)
110112 }
111113112114 return buf.Bytes(), "jpeg", nil
115115+}
116116+117117+// SaveOriginalAvatar saves the original avatar to disk for quality preservation
118118+func (p *Processor) SaveOriginalAvatar(data []byte, path string) error {
119119+ return os.WriteFile(path, data, 0644)
120120+}
121121+122122+// LoadOriginalAvatar loads the original avatar from disk
123123+func (p *Processor) LoadOriginalAvatar(path string) ([]byte, error) {
124124+ return os.ReadFile(path)
113125}
114126115127// GetMimeType returns the MIME type for an image format
+44
internal/state/manager.go
···2525 LastProcessedAt string `json:"last_processed_at"`
2626 CumulativeRotation float64 `json:"cumulative_rotation"`
2727 ProcessedNotifications map[string]bool `json:"processed_notifications"` // Track URIs we've processed
2828+2929+ // Original avatar tracking for quality preservation
3030+ OriginalAvatarCID string `json:"original_avatar_cid"` // Blob CID of cached original
3131+ OriginalAvatarPath string `json:"original_avatar_path"` // Local file path
2832}
29333034// NewManager creates a new state manager
···134138 }
135139 }
136140141141+ // Load original avatar tracking fields
142142+ row = m.db.QueryRow("SELECT value FROM state WHERE key = ?", "original_avatar_cid")
143143+ var originalCID string
144144+ if err := row.Scan(&originalCID); err != nil && err != sql.ErrNoRows {
145145+ return nil, fmt.Errorf("failed to load original_avatar_cid: %w", err)
146146+ }
147147+ state.OriginalAvatarCID = originalCID
148148+149149+ row = m.db.QueryRow("SELECT value FROM state WHERE key = ?", "original_avatar_path")
150150+ var originalPath string
151151+ if err := row.Scan(&originalPath); err != nil && err != sql.ErrNoRows {
152152+ return nil, fmt.Errorf("failed to load original_avatar_path: %w", err)
153153+ }
154154+ state.OriginalAvatarPath = originalPath
155155+156156+ // Set default path if not set
157157+ if state.OriginalAvatarPath == "" {
158158+ state.OriginalAvatarPath = "/data/original-avatar.jpg"
159159+ }
160160+137161 return state, nil
138162}
139163···178202 return fmt.Errorf("failed to save processed_notifications: %w", err)
179203 }
180204205205+ // Save original avatar tracking fields
206206+ _, err = tx.Exec(`
207207+ INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)
208208+ `, "original_avatar_cid", state.OriginalAvatarCID)
209209+ if err != nil {
210210+ return fmt.Errorf("failed to save original_avatar_cid: %w", err)
211211+ }
212212+213213+ _, err = tx.Exec(`
214214+ INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)
215215+ `, "original_avatar_path", state.OriginalAvatarPath)
216216+ if err != nil {
217217+ return fmt.Errorf("failed to save original_avatar_path: %w", err)
218218+ }
219219+181220 return tx.Commit()
182221}
183222···201240 // Initialize map if nil
202241 if state.ProcessedNotifications == nil {
203242 state.ProcessedNotifications = make(map[string]bool)
243243+ }
244244+245245+ // Set default path if not set
246246+ if state.OriginalAvatarPath == "" {
247247+ state.OriginalAvatarPath = "/data/original-avatar.jpg"
204248 }
205249206250 return &state, nil