Files
dotfiles/projects/go/change-wallpaper/change-wallpaper.go
2025-11-30 12:25:38 -08:00

404 lines
10 KiB
Go

package main
import (
"encoding/json"
"fmt"
"image"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
_ "image/gif"
"image/jpeg"
"image/png"
"golang.org/x/image/draw"
)
const (
wallhavenAPI = "https://wallhaven.cc/api/v1"
wallpaperDir = "Pictures/wallpapers/wallhaven"
)
type Config struct {
Topics []string `json:"topics"`
Keep int `json:"keep"` // Number of wallpapers to keep (0 = never delete)
WallpaperDir string `json:"wallpaperDir"` // Directory to store wallpapers
}
var defaultTopics = []string{
"lofi",
}
const defaultKeep = 10
func loadConfig() (topics []string, keep int, wallpaperDir string) {
homeDir, err := os.UserHomeDir()
if err != nil {
return defaultTopics, defaultKeep, wallpaperDir
}
configPath := filepath.Join(homeDir, ".config", "change-wallpaper", "config.json")
file, err := os.Open(configPath)
if err != nil {
return defaultTopics, defaultKeep, wallpaperDir
}
defer file.Close()
var cfg Config
if err := json.NewDecoder(file).Decode(&cfg); err != nil {
return defaultTopics, defaultKeep, wallpaperDir
}
if len(cfg.Topics) == 0 {
cfg.Topics = defaultTopics
}
if cfg.Keep < 0 {
cfg.Keep = defaultKeep
}
if cfg.WallpaperDir == "" {
cfg.WallpaperDir = wallpaperDir
}
return cfg.Topics, cfg.Keep, cfg.WallpaperDir
}
type WallhavenResponse struct {
Data []struct {
Path string `json:"path"`
} `json:"data"`
}
type monitor struct {
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
}
func main() {
// Initialize random source
r := rand.New(rand.NewSource(time.Now().UnixNano()))
// Load topics from config or use defaults
topics, keep, configWallpaperDir := loadConfig()
// Check if a file path was provided as argument
if len(os.Args) > 1 {
imgPath := os.Args[1]
if _, err := os.Stat(imgPath); err == nil {
changeWallpaper(imgPath, "")
return
}
}
// Create wallpaper directory if it doesn't exist
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
// Use config wallpaper directory if set, otherwise use default
var wallpaperPath string
if configWallpaperDir != "" {
if strings.HasPrefix(configWallpaperDir, "~/") {
wallpaperPath = filepath.Join(homeDir, configWallpaperDir[2:])
} else if configWallpaperDir == "~" {
wallpaperPath = homeDir
} else {
wallpaperPath = configWallpaperDir
}
} else {
wallpaperPath = filepath.Join(homeDir, wallpaperDir)
}
if err := os.MkdirAll(wallpaperPath, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating wallpaper directory: %v\n", err)
os.Exit(1)
}
// Download and set new wallpaper
newWallpaper, topic := downloadRandomWallpaper(wallpaperPath, r, topics, keep)
if newWallpaper != "" {
changeWallpaper(newWallpaper, topic)
} else {
notify("Failed to download new wallpaper", "critical")
os.Exit(1)
}
}
func downloadRandomWallpaper(wallpaperPath string, r *rand.Rand, topics []string, keep int) (string, string) {
// Clean up old wallpapers before downloading a new one, if keep > 0
if keep > 0 {
files, err := os.ReadDir(wallpaperPath)
if err == nil && len(files) > keep {
type fileInfo struct {
name string
mod int64
}
var fileInfos []fileInfo
for _, f := range files {
if !f.IsDir() {
info, err := f.Info()
if err == nil {
fileInfos = append(fileInfos, fileInfo{f.Name(), info.ModTime().Unix()})
}
}
}
// Sort by mod time, newest first
sort.Slice(fileInfos, func(i, j int) bool { return fileInfos[i].mod > fileInfos[j].mod })
for _, f := range fileInfos[keep:] {
os.Remove(filepath.Join(wallpaperPath, f.name))
}
}
}
// If keep == 0, never delete
// Select random topic
topic := topics[r.Intn(len(topics))]
var query string
var displayName string
// Check if the topic is a tag ID with name
if tagRegex := regexp.MustCompile(`^(\d+)\s*-\s*(.+)$`); tagRegex.MatchString(topic) {
matches := tagRegex.FindStringSubmatch(topic)
query = fmt.Sprintf("id:%s", matches[1])
displayName = strings.TrimSpace(matches[2])
} else {
query = url.QueryEscape(topic)
displayName = topic
}
fmt.Fprintf(os.Stderr, "Searching for wallpapers related to: %s\n", displayName)
// Get wallpapers from Wallhaven API
resp, err := http.Get(fmt.Sprintf("%s/search?q=%s&purity=100&categories=110&sorting=random", wallhavenAPI, query))
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching from Wallhaven: %v\n", err)
return "", ""
}
defer resp.Body.Close()
// Parse response
var wallhavenResp WallhavenResponse
if err := json.NewDecoder(resp.Body).Decode(&wallhavenResp); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
return "", ""
}
if len(wallhavenResp.Data) == 0 {
fmt.Fprintf(os.Stderr, "No wallpapers found for topic: %s\n", displayName)
return "", ""
}
// Select a random image from the results
randomIndex := r.Intn(len(wallhavenResp.Data))
wallpaperURL := wallhavenResp.Data[randomIndex].Path
orig := filepath.Base(wallpaperURL)
sanitizedTopic := strings.ReplaceAll(strings.ToLower(displayName), " ", "-")
filename := fmt.Sprintf("%s-%s", sanitizedTopic, orig)
filepath := filepath.Join(wallpaperPath, filename)
fmt.Fprintf(os.Stderr, "Downloading: %s\n", filename)
resp, err = http.Get(wallpaperURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Error downloading wallpaper: %v\n", err)
return "", ""
}
defer resp.Body.Close()
file, err := os.Create(filepath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating file: %v\n", err)
return "", ""
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
fmt.Fprintf(os.Stderr, "Error saving wallpaper: %v\n", err)
return "", ""
}
return filepath, displayName
}
func activeMonitors() ([]monitor, error) {
out, err := exec.Command("hyprctl", "-j", "monitors").Output()
if err != nil {
return nil, err
}
var monitors []monitor
if err := json.Unmarshal(out, &monitors); err != nil {
return nil, err
}
return monitors, nil
}
func ensureSized(wallpaperPath string) (string, error) {
monitors, err := activeMonitors()
if err != nil {
return "", err
}
if len(monitors) == 0 {
return wallpaperPath, nil
}
targetWidth := 0
targetHeight := 0
for _, m := range monitors {
if m.Width > targetWidth {
targetWidth = m.Width
}
if m.Height > targetHeight {
targetHeight = m.Height
}
}
if targetWidth == 0 || targetHeight == 0 {
return wallpaperPath, nil
}
file, err := os.Open(wallpaperPath)
if err != nil {
return "", err
}
defer file.Close()
src, format, err := image.Decode(file)
if err != nil {
return "", err
}
srcWidth := float64(src.Bounds().Dx())
srcHeight := float64(src.Bounds().Dy())
targetW := float64(targetWidth)
targetH := float64(targetHeight)
// Calculate scale factor to fit image within target while maintaining aspect ratio
scaleW := targetW / srcWidth
scaleH := targetH / srcHeight
scale := min(scaleW, scaleH)
// If image already fits within target dimensions, no resize needed
if scale >= 1.0 {
return wallpaperPath, nil
}
// Calculate new dimensions maintaining aspect ratio (best fit)
newWidth := int(srcWidth * scale)
newHeight := int(srcHeight * scale)
dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
var ext string
switch format {
case "jpeg":
ext = ".jpg"
case "png":
ext = ".png"
case "gif":
ext = ".png"
default:
ext = filepath.Ext(wallpaperPath)
if ext == "" {
ext = ".jpg"
}
}
base := strings.TrimSuffix(filepath.Base(wallpaperPath), filepath.Ext(wallpaperPath))
resizedPath := filepath.Join(filepath.Dir(wallpaperPath), fmt.Sprintf("%s-%dx%d%s", base, newWidth, newHeight, ext))
outFile, err := os.Create(resizedPath)
if err != nil {
return "", err
}
defer outFile.Close()
switch format {
case "png", "gif":
if err := png.Encode(outFile, dst); err != nil {
return "", err
}
default:
if err := jpeg.Encode(outFile, dst, &jpeg.Options{Quality: 90}); err != nil {
return "", err
}
}
return resizedPath, nil
}
func changeWallpaper(wallpaperPath, topic string) {
resizedPath, err := ensureSized(wallpaperPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resizing wallpaper: %v\n", err)
resizedPath = wallpaperPath
}
if resizedPath == "" {
resizedPath = wallpaperPath
}
// Save current wallpaper path
homeDir, _ := os.UserHomeDir()
wallpaperFile := filepath.Join(homeDir, ".wallpaper")
if err := os.WriteFile(wallpaperFile, []byte(resizedPath), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error saving wallpaper path: %v\n", err)
}
monitors, monitorErr := activeMonitors()
if monitorErr != nil {
fmt.Fprintf(os.Stderr, "Error getting monitors: %v\n", monitorErr)
}
cmd := exec.Command("hyprctl", "hyprpaper", "preload", resizedPath)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error preloading wallpaper: %v\n", err)
}
if monitorErr != nil || len(monitors) == 0 {
cmd = exec.Command("hyprctl", "hyprpaper", "wallpaper", ","+resizedPath)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error applying wallpaper: %v\n", err)
}
} else {
for _, m := range monitors {
cmd = exec.Command("hyprctl", "hyprpaper", "wallpaper", fmt.Sprintf("%s,%s", m.Name, resizedPath))
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error applying wallpaper for monitor %s: %v\n", m.Name, err)
}
}
}
cmd = exec.Command("hyprctl", "hyprpaper", "reload")
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error reloading hyprpaper: %v\n", err)
}
// Send notification with wallpaper as icon
filename := filepath.Base(resizedPath)
message := fmt.Sprintf("Wallpaper changed to %s", filename)
if topic != "" {
message += fmt.Sprintf(" (%s)", topic)
}
notifyWithIcon(message, "normal", resizedPath)
}
func notify(message, urgency string) {
cmd := exec.Command("notify-send", "-a", "change-wallpaper", "-i", "hyprpaper", "-u", urgency, "change-wallpaper.go", message)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error sending notification: %v\n", err)
}
}
// notifyWithIcon sends a notification with a custom icon (wallpaper image)
func notifyWithIcon(message, urgency, iconPath string) {
cmd := exec.Command("notify-send", "-a", "change-wallpaper", "-i", iconPath, "-u", urgency, "change-wallpaper.go", message)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error sending notification: %v\n", err)
}
}