mirror of
				https://github.com/skidoodle/spotify-ws
				synced 2025-10-09 05:22:43 +02:00 
			
		
		
		
	refactor
This commit is contained in:
		
							
								
								
									
										369
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										369
									
								
								main.go
									
									
									
									
									
								
							| @@ -3,364 +3,45 @@ package main | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"log/slog" | ||||
| 	"os" | ||||
| 	"os/signal" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"syscall" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gorilla/websocket" | ||||
| 	"github.com/joho/godotenv" | ||||
| 	"github.com/sirupsen/logrus" | ||||
| 	"github.com/zmb3/spotify" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
|  | ||||
| // Configuration holds application settings | ||||
| type Configuration struct { | ||||
| 	ServerPort     string | ||||
| 	AllowedOrigins []string | ||||
| 	LogLevel       logrus.Level | ||||
| 	Spotify        struct { | ||||
| 		ClientID     string | ||||
| 		ClientSecret string | ||||
| 		RefreshToken string | ||||
| 	} | ||||
| } | ||||
|  | ||||
| var ( | ||||
| 	config       Configuration | ||||
| 	clients      = make(map[*websocket.Conn]bool) | ||||
| 	clientsMutex sync.RWMutex | ||||
| 	broadcast    = make(chan *spotify.CurrentlyPlaying) | ||||
| 	connectChan  = make(chan *websocket.Conn) | ||||
|  | ||||
| 	upgrader = websocket.Upgrader{ | ||||
| 		HandshakeTimeout: 10 * time.Second, | ||||
| 		ReadBufferSize:   1024, | ||||
| 		WriteBufferSize:  1024, | ||||
| 	} | ||||
|  | ||||
| 	spotifyClient spotify.Client | ||||
| 	tokenSource   oauth2.TokenSource | ||||
| 	lastState     struct { | ||||
| 		sync.RWMutex | ||||
| 		track   *spotify.CurrentlyPlaying | ||||
| 		playing bool | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	defaultPort       = ":3000" | ||||
| 	tokenRefreshURL   = "https://accounts.spotify.com/api/token" | ||||
| 	apiRetryDelay     = 3 * time.Second | ||||
| 	heartbeatInterval = 3 * time.Second | ||||
| 	writeTimeout      = 10 * time.Second | ||||
| 	"spotify-ws/internal/config" | ||||
| 	"spotify-ws/internal/spotify" | ||||
| 	"spotify-ws/internal/websocket" | ||||
| ) | ||||
|  | ||||
| func main() { | ||||
| 	ctx, cancel := context.WithCancel(context.Background()) | ||||
| 	defer cancel() | ||||
| 	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||||
| 	defer stop() | ||||
|  | ||||
| 	initializeApplication() | ||||
| 	initializeSpotifyClient() | ||||
|  | ||||
| 	router := http.NewServeMux() | ||||
| 	router.HandleFunc("/", connectionHandler) | ||||
| 	router.HandleFunc("/health", healthHandler) | ||||
|  | ||||
| 	server := &http.Server{ | ||||
| 		Addr:    config.ServerPort, | ||||
| 		Handler: router, | ||||
| 	} | ||||
|  | ||||
| 	go trackFetcher(ctx) | ||||
| 	go messageHandler(ctx) | ||||
| 	go connectionManager(ctx) | ||||
|  | ||||
| 	startServer(server, ctx) | ||||
| 	handleShutdown(server, cancel) | ||||
| } | ||||
|  | ||||
| func initializeApplication() { | ||||
| 	logrus.SetFormatter(&logrus.TextFormatter{ | ||||
| 		FullTimestamp:   true, | ||||
| 		TimestampFormat: time.RFC3339, | ||||
| 		ForceColors:     true, | ||||
| 	}) | ||||
|  | ||||
| 	if err := loadConfiguration(); err != nil { | ||||
| 		logrus.Fatal(err) | ||||
| 	} | ||||
|  | ||||
| 	upgrader.CheckOrigin = func(r *http.Request) bool { | ||||
| 		if len(config.AllowedOrigins) == 0 { | ||||
| 			return true | ||||
| 		} | ||||
| 		origin := r.Header.Get("Origin") | ||||
| 		for _, allowed := range config.AllowedOrigins { | ||||
| 			if origin == allowed { | ||||
| 				return true | ||||
| 			} | ||||
| 		} | ||||
| 		return false | ||||
| 	if err := run(ctx); err != nil { | ||||
| 		_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func loadConfiguration() error { | ||||
| 	_ = godotenv.Load() | ||||
|  | ||||
| 	required := map[string]*string{ | ||||
| 		"CLIENT_ID":     &config.Spotify.ClientID, | ||||
| 		"CLIENT_SECRET": &config.Spotify.ClientSecret, | ||||
| 		"REFRESH_TOKEN": &config.Spotify.RefreshToken, | ||||
| func run(ctx context.Context) error { | ||||
| 	cfg, err := config.Load() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to load config: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	for key, ptr := range required { | ||||
| 		value := os.Getenv(key) | ||||
| 		if value == "" { | ||||
| 			return fmt.Errorf("missing required environment variable: %s", key) | ||||
| 		} | ||||
| 		*ptr = value | ||||
| 	handlerOptions := &slog.HandlerOptions{Level: cfg.LogLevel} | ||||
| 	logger := slog.New(slog.NewJSONHandler(os.Stdout, handlerOptions)) | ||||
| 	slog.SetDefault(logger) | ||||
|  | ||||
| 	spotifyClient := spotify.NewClient(ctx, cfg.Spotify.ClientID, cfg.Spotify.ClientSecret, cfg.Spotify.RefreshToken) | ||||
| 	wsServer := websocket.NewServer(":"+cfg.ServerPort, cfg.AllowedOrigins, spotifyClient, cfg.RT) | ||||
|  | ||||
| 	slog.Info("starting spotify-ws server", "port", cfg.ServerPort, "realtime", cfg.RT) | ||||
|  | ||||
| 	if err := wsServer.Run(ctx); err != nil { | ||||
| 		return fmt.Errorf("server runtime error: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	config.ServerPort = defaultPort | ||||
| 	if port := os.Getenv("SERVER_PORT"); port != "" { | ||||
| 		config.ServerPort = ":" + strings.TrimLeft(port, ":") | ||||
| 	} | ||||
|  | ||||
| 	config.AllowedOrigins = strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") | ||||
|  | ||||
| 	logLevel := strings.ToLower(os.Getenv("LOG_LEVEL")) | ||||
| 	switch logLevel { | ||||
| 	case "debug": | ||||
| 		config.LogLevel = logrus.DebugLevel | ||||
| 	case "warn": | ||||
| 		config.LogLevel = logrus.WarnLevel | ||||
| 	case "error": | ||||
| 		config.LogLevel = logrus.ErrorLevel | ||||
| 	default: | ||||
| 		config.LogLevel = logrus.InfoLevel | ||||
| 	} | ||||
| 	logrus.SetLevel(config.LogLevel) | ||||
|  | ||||
| 	slog.Info("server shut down gracefully") | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func initializeSpotifyClient() { | ||||
| 	token := &oauth2.Token{RefreshToken: config.Spotify.RefreshToken} | ||||
| 	oauthConfig := &oauth2.Config{ | ||||
| 		ClientID:     config.Spotify.ClientID, | ||||
| 		ClientSecret: config.Spotify.ClientSecret, | ||||
| 		Endpoint:     oauth2.Endpoint{TokenURL: tokenRefreshURL}, | ||||
| 	} | ||||
|  | ||||
| 	tokenSource = oauthConfig.TokenSource(context.Background(), token) | ||||
| 	spotifyClient = spotify.NewClient(oauth2.NewClient(context.Background(), tokenSource)) | ||||
|  | ||||
| 	logrus.Info("Spotify client initialized successfully") | ||||
| } | ||||
|  | ||||
| func startServer(server *http.Server, _ context.Context) { | ||||
| 	go func() { | ||||
| 		logrus.Infof("Server starting on %s", config.ServerPort) | ||||
| 		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { | ||||
| 			logrus.Fatalf("Server failed to start: %v", err) | ||||
| 		} | ||||
| 	}() | ||||
| } | ||||
|  | ||||
| func handleShutdown(server *http.Server, cancel context.CancelFunc) { | ||||
| 	sigChan := make(chan os.Signal, 1) | ||||
| 	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) | ||||
| 	<-sigChan | ||||
|  | ||||
| 	logrus.Info("Initiating graceful shutdown...") | ||||
| 	cancel() | ||||
|  | ||||
| 	ctx, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second) | ||||
| 	defer cancelTimeout() | ||||
|  | ||||
| 	clientsMutex.Lock() | ||||
| 	for client := range clients { | ||||
| 		client.Close() | ||||
| 	} | ||||
| 	clientsMutex.Unlock() | ||||
|  | ||||
| 	if err := server.Shutdown(ctx); err != nil { | ||||
| 		logrus.Errorf("Server shutdown error: %v", err) | ||||
| 	} | ||||
| 	logrus.Info("Server shutdown complete") | ||||
| } | ||||
|  | ||||
| func connectionHandler(w http.ResponseWriter, r *http.Request) { | ||||
| 	w.Header().Set("X-Source", "github.com/skidoodle/spotify-ws") | ||||
| 	ws, err := upgrader.Upgrade(w, r, nil) | ||||
| 	if err != nil { | ||||
| 		logrus.Errorf("WebSocket upgrade failed: %v", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// Add client to the pool | ||||
| 	clientsMutex.Lock() | ||||
| 	clients[ws] = true | ||||
| 	clientsMutex.Unlock() | ||||
|  | ||||
| 	logrus.Debugf("New client connected: %s", ws.RemoteAddr()) | ||||
|  | ||||
| 	// Send initial state if available | ||||
| 	sendInitialState(ws) | ||||
|  | ||||
| 	// Start monitoring the connection | ||||
| 	go monitorConnection(ws) | ||||
| } | ||||
|  | ||||
| func connectionManager(ctx context.Context) { | ||||
| 	for { | ||||
| 		select { | ||||
| 		case <-ctx.Done(): | ||||
| 			return | ||||
| 		case client := <-connectChan: | ||||
| 			// Add client to the pool | ||||
| 			clientsMutex.Lock() | ||||
| 			clients[client] = true | ||||
| 			clientsMutex.Unlock() | ||||
|  | ||||
| 			logrus.Debugf("New client connected: %s", client.RemoteAddr()) | ||||
|  | ||||
| 			// Send initial state if available | ||||
| 			sendInitialState(client) | ||||
|  | ||||
| 			// Start monitoring the connection | ||||
| 			go monitorConnection(client) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func monitorConnection(ws *websocket.Conn) { | ||||
| 	defer func() { | ||||
| 		// Clean up the connection | ||||
| 		clientsMutex.Lock() | ||||
| 		delete(clients, ws) | ||||
| 		clientsMutex.Unlock() | ||||
|  | ||||
| 		// Close the WebSocket connection | ||||
| 		ws.Close() | ||||
| 		logrus.Debugf("Client disconnected: %s", ws.RemoteAddr()) | ||||
| 	}() | ||||
|  | ||||
| 	for { | ||||
| 		// Set a read deadline to detect dead connections | ||||
| 		ws.SetReadDeadline(time.Now().Add(30 * time.Second)) | ||||
|  | ||||
| 		// Attempt to read a message (even though we don't expect any) | ||||
| 		_, _, err := ws.NextReader() | ||||
| 		if err != nil { | ||||
| 			// Check if the error is a normal closure | ||||
| 			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { | ||||
| 				logrus.Debugf("Client disconnected unexpectedly: %v", err) | ||||
| 			} | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func sendInitialState(client *websocket.Conn) { | ||||
| 	lastState.RLock() | ||||
| 	defer lastState.RUnlock() | ||||
|  | ||||
| 	if lastState.track == nil { | ||||
| 		logrus.Debug("No initial state available to send") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := client.WriteJSON(lastState.track); err != nil { | ||||
| 		logrus.Errorf("Failed to send initial state: %v", err) | ||||
| 		client.Close() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func messageHandler(ctx context.Context) { | ||||
| 	for { | ||||
| 		select { | ||||
| 		case <-ctx.Done(): | ||||
| 			return | ||||
| 		case msg := <-broadcast: | ||||
| 			broadcastToClients(msg) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func broadcastToClients(msg *spotify.CurrentlyPlaying) { | ||||
| 	clientsMutex.RLock() | ||||
| 	defer clientsMutex.RUnlock() | ||||
|  | ||||
| 	for client := range clients { | ||||
| 		client.SetWriteDeadline(time.Now().Add(writeTimeout)) | ||||
| 		if err := client.WriteJSON(msg); err != nil { | ||||
| 			logrus.Debugf("Broadcast failed: %v", err) | ||||
| 			client.Close() | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func trackFetcher(ctx context.Context) { | ||||
| 	ticker := time.NewTicker(heartbeatInterval) | ||||
| 	defer ticker.Stop() | ||||
|  | ||||
| 	for { | ||||
| 		select { | ||||
| 		case <-ctx.Done(): | ||||
| 			return | ||||
| 		case <-ticker.C: | ||||
| 			fetchAndBroadcastState() | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func fetchAndBroadcastState() { | ||||
| 	current, err := spotifyClient.PlayerCurrentlyPlaying() | ||||
| 	if err != nil { | ||||
| 		logrus.Errorf("Failed to fetch playback state: %v", err) | ||||
| 		time.Sleep(apiRetryDelay) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	logrus.Debugf("Fetched playback state: %+v", current) | ||||
|  | ||||
| 	updateState(current) | ||||
| } | ||||
|  | ||||
| func updateState(current *spotify.CurrentlyPlaying) { | ||||
| 	lastState.Lock() | ||||
| 	defer lastState.Unlock() | ||||
|  | ||||
| 	if current == nil { | ||||
| 		logrus.Warn("Received nil playback state from Spotify") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if lastState.track == nil { | ||||
| 		lastState.track = &spotify.CurrentlyPlaying{} | ||||
| 	} | ||||
|  | ||||
| 	stateChanged := lastState.track.Item == nil || | ||||
| 		current.Item == nil || | ||||
| 		lastState.track.Item.ID != current.Item.ID || | ||||
| 		lastState.playing != current.Playing | ||||
|  | ||||
| 	lastState.track = current | ||||
| 	lastState.playing = current.Playing | ||||
|  | ||||
| 	if stateChanged || current.Playing { | ||||
| 		broadcast <- current | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func healthHandler(w http.ResponseWriter, r *http.Request) { | ||||
| 	w.Header().Set("Content-Type", "text/plain") | ||||
| 	w.WriteHeader(http.StatusOK) | ||||
| 	w.Write([]byte("OK")) | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user