diff --git a/.env.example b/.env.example index f361f60..a4e4a01 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ CLIENT_ID= CLIENT_SECRET= REFRESH_TOKEN= +# LOG_LEVEL=DEBUG (optional) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e05fb40..3851826 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.22.3' + go-version: '1.22.4' - name: Build run: go build -v ./... diff --git a/Dockerfile b/Dockerfile index db7d588..6be56ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,14 @@ FROM golang:alpine as builder +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o spotify-ws . -FROM alpine:latest -RUN apk --no-cache add ca-certificates -WORKDIR /root/ +FROM gcr.io/distroless/static:nonroot +WORKDIR /app COPY --from=builder /app/spotify-ws . EXPOSE 3000 +USER nonroot:nonroot CMD ["./spotify-ws"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 268dd14..ccbdf8b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,3 +9,4 @@ services: - REFRESH_TOKEN= - CLIENT_SECRET= - CLIENT_ID= + #- LOG_LEVEL=DEBUG diff --git a/go.mod b/go.mod index bd6440b..8187cfc 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,13 @@ module spotify-ws -go 1.22.3 +go 1.22.4 require ( - github.com/gorilla/websocket v1.5.1 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 + github.com/sirupsen/logrus v1.9.3 github.com/zmb3/spotify v1.3.0 - golang.org/x/oauth2 v0.20.0 + golang.org/x/oauth2 v0.24.0 ) -require golang.org/x/net v0.25.0 // indirect +require golang.org/x/sys v0.28.0 // indirect diff --git a/go.sum b/go.sum index 1941523..cf511e9 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,18 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -17,12 +20,13 @@ github.com/zmb3/spotify v1.3.0 h1:6Z2F1IMx0Hviq/dpf8nFwvKPppFEMXn8yfReSBVi16k= github.com/zmb3/spotify v1.3.0/go.mod h1:GD7AAEMUJVYc2Z7p2a2S0E3/5f/KxM/vOnErNr4j+Tw= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= -golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index 45b0a30..3d19351 100644 --- a/main.go +++ b/main.go @@ -2,202 +2,231 @@ package main import ( "context" - "log" "net/http" "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" ) +const ( + serverPort = ":3000" + tokenRefreshURL = "https://accounts.spotify.com/api/token" + apiRetryDelay = 3 * time.Second + heartbeatDelay = 3 * time.Second +) + var ( - clients = make(map[*websocket.Conn]bool) // Map to keep track of connected clients - clientsMutex sync.Mutex // Mutex to protect access to clients map - broadcast = make(chan *spotify.CurrentlyPlaying) // Channel for broadcasting currently playing track - connect = make(chan *websocket.Conn) // Channel for managing new connections - disconnect = make(chan *websocket.Conn) // Channel for managing client disconnections - upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, // Allow all origins - HandshakeTimeout: 10 * time.Second, // Timeout for WebSocket handshake - ReadBufferSize: 1024, // Buffer size for reading incoming messages - WriteBufferSize: 1024, // Buffer size for writing outgoing messages - Subprotocols: []string{"binary"}, // Supported subprotocols + clients = make(map[*websocket.Conn]bool) + clientsMutex sync.RWMutex + broadcast = make(chan *spotify.CurrentlyPlaying) + connect = make(chan *websocket.Conn) + disconnect = make(chan *websocket.Conn) + + upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + HandshakeTimeout: 10 * time.Second, + ReadBufferSize: 1024, + WriteBufferSize: 1024, } - spotifyClient spotify.Client // Spotify API client - tokenSource oauth2.TokenSource // OAuth2 token source - config *oauth2.Config // OAuth2 configuration + + spotifyClient spotify.Client + tokenSource oauth2.TokenSource + config *oauth2.Config + lastPlayingState *bool + lastTrackState *spotify.CurrentlyPlaying ) func main() { - // Load environment variables from .env file if not already set - if os.Getenv("CLIENT_ID") == "" || os.Getenv("CLIENT_SECRET") == "" || os.Getenv("REFRESH_TOKEN") == "" { - if err := godotenv.Load(); err != nil { - log.Fatalf("Error loading .env file: %v", err) - } - } + logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true}) + loadEnv() - clientID := os.Getenv("CLIENT_ID") - clientSecret := os.Getenv("CLIENT_SECRET") - refreshToken := os.Getenv("REFRESH_TOKEN") - - // Setup OAuth2 configuration for Spotify API config = &oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, + ClientID: os.Getenv("CLIENT_ID"), + ClientSecret: os.Getenv("CLIENT_SECRET"), Endpoint: oauth2.Endpoint{ - TokenURL: "https://accounts.spotify.com/api/token", + TokenURL: tokenRefreshURL, }, } - - token := &oauth2.Token{RefreshToken: refreshToken} + token := &oauth2.Token{RefreshToken: os.Getenv("REFRESH_TOKEN")} tokenSource = config.TokenSource(context.Background(), token) - // Create an OAuth2 HTTP client httpClient := oauth2.NewClient(context.Background(), tokenSource) spotifyClient = spotify.NewClient(httpClient) - // Handle WebSocket connections at the root endpoint - http.HandleFunc("/", ConnectionHandler) + http.HandleFunc("/", connectionHandler) + http.HandleFunc("/health", healthHandler) - // Log server start-up and initialize background tasks - log.Println("Server started on :3000") - go TrackFetcher() // Periodically fetch currently playing track from Spotify - go MessageHandler() // Broadcast messages to connected clients - go ConnectionManager() // Manage client connections + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - // Start the HTTP server - if err := http.ListenAndServe(":3000", nil); err != nil { - log.Fatalf("Error starting server: %v", err) + go trackFetcher() + go messageHandler() + go connectionManager() + + server := &http.Server{ + Addr: serverPort, + } + + go func() { + logrus.Infof("Server started on %s", serverPort) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logrus.Fatalf("Server failed: %v", err) + } + }() + + <-stop + logrus.Info("Shutting down server...") + + clientsMutex.Lock() + for client := range clients { + _ = client.Close() + } + clientsMutex.Unlock() + + if err := server.Shutdown(context.Background()); err != nil { + logrus.Fatalf("Server shutdown failed: %v", err) + } + logrus.Info("Server exited cleanly") +} + +func loadEnv() { + if err := godotenv.Load(); err != nil { + logrus.Warn("Could not load .env file, falling back to system environment") + } + + requiredVars := []string{"CLIENT_ID", "CLIENT_SECRET", "REFRESH_TOKEN"} + for _, v := range requiredVars { + if os.Getenv(v) == "" { + logrus.Fatalf("Missing required environment variable: %s", v) + } + } + + logLevel := strings.ToLower(os.Getenv("LOG_LEVEL")) + if logLevel == "debug" { + logrus.SetLevel(logrus.DebugLevel) + logrus.Info("Log level set to DEBUG") + } else { + logrus.SetLevel(logrus.InfoLevel) + logrus.Info("Log level set to INFO") } } -// ConnectionHandler upgrades HTTP connections to WebSocket and handles communication with clients -func ConnectionHandler(w http.ResponseWriter, r *http.Request) { +func connectionHandler(w http.ResponseWriter, r *http.Request) { ws, err := upgrader.Upgrade(w, r, nil) if err != nil { + logrus.Errorf("Failed to upgrade to WebSocket: %v", err) return } connect <- ws + clientsMutex.RLock() + if lastTrackState != nil { + if err := ws.WriteJSON(lastTrackState); err != nil { + logrus.Errorf("Failed to send initial state to client: %v", err) + } + } + clientsMutex.RUnlock() + defer func() { disconnect <- ws - err := ws.Close() - if err != nil { - return - } }() - // Immediately send the current track to the newly connected client - currentTrack, err := spotifyClient.PlayerCurrentlyPlaying() - if err != nil { - log.Printf("Error getting currently playing track: %v", err) - return - } - - // Send the current track information to the client - err = ws.WriteJSON(currentTrack) - if err != nil { - log.Printf("Error sending current track to client: %v", err) - return - } - - // Keep the connection open to listen for incoming messages (heartbeat) for { - _, _, err := ws.ReadMessage() - if err != nil { - disconnect <- ws + if _, _, err := ws.ReadMessage(); err != nil { break } } } -// ConnectionManager manages client connections and disconnections using channels -func ConnectionManager() { +func connectionManager() { for { select { case client := <-connect: clientsMutex.Lock() clients[client] = true clientsMutex.Unlock() + logrus.Debugf("New client connected: %v", client.RemoteAddr()) case client := <-disconnect: clientsMutex.Lock() if _, ok := clients[client]; ok { delete(clients, client) - err := client.Close() - if err != nil { - return - } + logrus.Debugf("Client disconnected: %v", client.RemoteAddr()) } clientsMutex.Unlock() } } } -// MessageHandler continuously listens for messages on the broadcast channel and sends them to all connected clients -func MessageHandler() { +func messageHandler() { for msg := range broadcast { - clientsMutex.Lock() + clientsMutex.RLock() for client := range clients { - err := client.WriteJSON(msg) - if err != nil { - err := client.Close() - if err != nil { - return - } - delete(clients, client) + if err := client.WriteJSON(msg); err != nil { + logrus.Errorf("Failed to send message to client: %v", err) + disconnect <- client } } - clientsMutex.Unlock() + clientsMutex.RUnlock() } } -// TrackFetcher periodically fetches the currently playing track from the Spotify API and broadcasts it to clients -func TrackFetcher() { - var playing bool +func trackFetcher() { for { - // Fetch the currently playing track - current, err := spotifyClient.PlayerCurrentlyPlaying() + current, err := fetchCurrentlyPlaying() if err != nil { - log.Printf("Error getting currently playing track: %v", err) - // Refresh the access token if it has expired - if err.Error() == "token expired" { - log.Println("Token expired, refreshing token...") - newToken, err := tokenSource.Token() - if err != nil { - log.Fatalf("Couldn't refresh token: %v", err) - } - httpClient := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(newToken)) - spotifyClient = spotify.NewClient(httpClient) - } - // Wait before retrying to avoid overwhelming the API - time.Sleep(30 * time.Minute) + logrus.Errorf("Error fetching currently playing track: %v", err) + time.Sleep(apiRetryDelay) continue } - // Check if the track is playing - switch { - case current.Playing: - broadcast <- current - playing = true - // Send updates every 3 seconds while playing - for current.Playing { - time.Sleep(3 * time.Second) - current, err = spotifyClient.PlayerCurrentlyPlaying() - if err != nil { - log.Printf("Error getting currently playing track: %v", err) - break - } - broadcast <- current - } - case !current.Playing && playing: - playing = false - } + if current != nil { + clientsMutex.Lock() + lastTrackState = current + clientsMutex.Unlock() - // Wait before checking again to avoid overwhelming the API - time.Sleep(3 * time.Second) + if lastPlayingState == nil || *lastPlayingState != current.Playing { + logrus.Debugf("Playback state changed: is_playing=%v", current.Playing) + broadcast <- current + lastPlayingState = ¤t.Playing + } + + if current.Playing { + broadcast <- current + time.Sleep(heartbeatDelay) + continue + } + } + time.Sleep(heartbeatDelay) } } + +func fetchCurrentlyPlaying() (*spotify.CurrentlyPlaying, error) { + current, err := spotifyClient.PlayerCurrentlyPlaying() + if err != nil && err.Error() == "token expired" { + logrus.Warn("Spotify token expired, refreshing token...") + newToken, err := tokenSource.Token() + if err != nil { + return nil, err + } + httpClient := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(newToken)) + spotifyClient = spotify.NewClient(httpClient) + return spotifyClient.PlayerCurrentlyPlaying() + } + return current, err +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/readme.md b/readme.md index d1ee3e6..326cde2 100644 --- a/readme.md +++ b/readme.md @@ -8,7 +8,7 @@ It's important to note that this project does not purport to resolve the challen ### With Docker -``` +```sh git clone https://github.com/skidoodle/spotify-ws cd spotify-ws docker build -t spotify-ws:main . @@ -17,7 +17,7 @@ docker run -p 3000:3000 spotify-ws:main ### Without Docker -``` +```sh git clone https://github.com/skidoodle/spotify-ws cd spotify-ws go get @@ -38,6 +38,7 @@ services: - REFRESH_TOKEN= - CLIENT_SECRET= - CLIENT_ID= + #- LOG_LEVEL=debug ports: - '3000:3000' ``` @@ -53,6 +54,7 @@ docker run \ -e CLIENT_ID= \ -e CLIENT_SECRET= \ -e REFRESH_TOKEN= \ + #-e LOG_LEVEL=DEBUG \ ghcr.io/skidoodle/spotify-ws:main ```