initial commit

This commit is contained in:
2023-08-25 22:44:44 +02:00
commit 9848740dbf
12 changed files with 951 additions and 0 deletions

91
internal/cron.go Normal file
View File

@@ -0,0 +1,91 @@
package morningalarm
import (
"encoding/json"
"os"
"time"
"github.com/robfig/cron/v3"
)
func (ma *MorningAlarm) nextAlarm() *time.Time {
if len(ma.cr.Entries()) == 0 {
return nil
}
min := ma.cr.Entries()[0].Next
for _, entry := range ma.cr.Entries() {
if entry.Next.Before(min) {
min = entry.Next
}
}
return &min
}
func (ma *MorningAlarm) addAlarm(spec string, name string) (cron.EntryID, error) {
id, err := ma.cr.AddFunc(spec, ma.fireAlarm)
if err != nil {
return 0, err
}
ma.alarms = append(ma.alarms, alarm{
id: id,
Time: spec,
Name: name,
})
return id, nil
}
func (ma *MorningAlarm) saveAlarms() error {
content, err := json.Marshal(ma.alarms)
if err != nil {
return err
}
err = os.WriteFile("alarms.json", content, 0644)
if err != nil {
return err
}
return nil
}
func (ma *MorningAlarm) loadAlarms() error {
if _, err := os.Stat("alarms.json"); os.IsNotExist(err) {
ma.alarms = []alarm{}
return nil
}
content, err := os.ReadFile("alarms.json")
if err != nil {
return err
}
var ent []alarm
err = json.Unmarshal(content, &ent)
if err != nil {
return err
}
for _, entry := range ent {
_, err := ma.addAlarm(entry.Time, entry.Name)
if err != nil {
return err
}
}
return nil
}
func (ma *MorningAlarm) fireAlarm() {
ma.playWakeUpMusic()
}

50
internal/morningalarm.go Normal file
View File

@@ -0,0 +1,50 @@
package morningalarm
import (
"github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
"github.com/zmb3/spotify/v2"
)
type alarm struct {
Name string `json:"name" binding:"required"`
Time string `json:"time" binding:"required"`
id cron.EntryID
}
type MorningAlarmConfig struct {
SpotifyClientID string `json:"spotifyClientId"`
SpotifyClientSecret string `json:"spotifyClientSecret"`
DeviceID string `json:"deviceId"`
PlaylistID string `json:"wakeupContext"`
RedirectURL string `json:"redirectUrl"`
ListenAddr string `json:"listenAddr"`
}
type MorningAlarm struct {
config MorningAlarmConfig
sp *spotify.Client
cr *cron.Cron
ro *gin.Engine
alarms []alarm
}
func New(config MorningAlarmConfig) *MorningAlarm {
return &MorningAlarm{
config: config,
}
}
func (ma *MorningAlarm) Start() {
ma.cr = cron.New()
ma.loadAlarms()
ma.cr.Start()
ma.ro = gin.Default()
ma.setupSpotify()
ma.setupWebserver()
ma.ro.Run(ma.config.ListenAddr)
}

136
internal/spotify.go Normal file
View File

@@ -0,0 +1,136 @@
package morningalarm
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/zmb3/spotify/v2"
spotifyauth "github.com/zmb3/spotify/v2/auth"
"golang.org/x/oauth2"
)
const redirectURI = "/api/spotifycb"
func (ma *MorningAlarm) setupSpotify() error {
auth := spotifyauth.New(
spotifyauth.WithRedirectURL(ma.config.RedirectURL+redirectURI),
spotifyauth.WithClientID(ma.config.SpotifyClientID),
spotifyauth.WithClientSecret(ma.config.SpotifyClientSecret),
spotifyauth.WithScopes(
spotifyauth.ScopeUserReadPrivate,
spotifyauth.ScopeUserModifyPlaybackState,
spotifyauth.ScopeStreaming,
spotifyauth.ScopeUserReadPlaybackState,
),
)
loadedTokenJSON, err := os.ReadFile("spotifyToken.json")
if err == nil {
fmt.Println("Found access token in file")
var token oauth2.Token
err = json.Unmarshal(loadedTokenJSON, &token)
if err != nil {
return err
}
ma.sp = spotify.New(auth.Client(context.Background(), &token), spotify.WithRetry(true))
newToken, err := ma.sp.Token()
if err != nil {
return err
}
tokenJSON, err := json.Marshal(newToken)
if err != nil {
return err
}
os.WriteFile("spotifyToken.json", tokenJSON, os.ModePerm)
return nil
}
state := "abc123"
ma.ro.GET(redirectURI, func(c *gin.Context) {
token, err := auth.Token(c, state, c.Request)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokenJSON, err := json.Marshal(token)
os.WriteFile("spotifyToken.json", tokenJSON, os.ModePerm)
ma.sp = spotify.New(auth.Client(c, token))
})
url := auth.AuthURL(state)
fmt.Println("Please log in to Spotify by visiting the following page in your browser:", url)
return nil
}
// playWakeUpMusic plays the playlist specified in the config at the specified device.
// Returns true if the playback was started successfully.
// Returns error if any error occurred.
// Can still return an error even if the playback was started successfully.
func (ma *MorningAlarm) playWakeUpMusic() (bool, error) {
playContextURI := spotify.URI("spotify:playlist:" + ma.config.PlaylistID)
deviceID := spotify.ID(ma.config.DeviceID)
playlist, err := ma.sp.GetPlaylistItems(context.Background(), spotify.ID(ma.config.PlaylistID))
if err != nil {
return true, err
}
randomTrackIndex := rand.Intn(playlist.Total)
err = ma.sp.PlayOpt(context.Background(), &spotify.PlayOptions{
PlaybackContext: &playContextURI,
DeviceID: &deviceID,
PlaybackOffset: &spotify.PlaybackOffset{Position: randomTrackIndex},
})
if err != nil {
return true, err
}
err = ma.sp.ShuffleOpt(context.Background(), true, &spotify.PlayOptions{
DeviceID: &deviceID,
})
return true, err
}
func (ma *MorningAlarm) getAvailableDevices() ([]Device, error) {
devices, err := ma.sp.PlayerDevices(context.Background())
if err != nil {
return nil, err
}
var availableDevices []Device
for _, device := range devices {
if !device.Restricted {
availableDevices = append(availableDevices, Device{
ID: string(device.ID),
Name: device.Name,
})
}
}
return availableDevices, nil
}

90
internal/webserver.go Normal file
View File

@@ -0,0 +1,90 @@
package morningalarm
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type Device struct {
Name string `json:"name"`
ID string `json:"id"`
}
func (ma *MorningAlarm) setupWebserver() {
ma.ro.POST("/api/alarm", func(c *gin.Context) {
var body alarm
if err := c.BindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := ma.addAlarm(body.Time, body.Name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} else {
err = ma.saveAlarms()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{})
}
})
ma.ro.GET("/api/info", func(c *gin.Context) {
spotifyUser, err := ma.sp.CurrentUser(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentTime := time.Now()
zone, offset := currentTime.Zone()
nextIn := ma.nextAlarm()
response := gin.H{
"spotifyUser": spotifyUser.User.DisplayName,
"serverTime": currentTime.Format("15:04:05"),
"timezone": zone,
"timezoneOffsetInH": offset / 60 / 60,
"alarms": len(ma.cr.Entries()),
"wakeupContext": ma.config.PlaylistID,
"deviceId": ma.config.DeviceID,
}
if nextIn != nil {
response["nextAlarmAt"] = nextIn.String()
response["nextAlarmIn"] = nextIn.Sub(currentTime).String()
}
c.JSON(http.StatusOK, response)
})
ma.ro.GET("/api/device", func(c *gin.Context) {
devices, err := ma.getAvailableDevices()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, devices)
})
ma.ro.POST("/api/trigger", func(c *gin.Context) {
if _, err := ma.playWakeUpMusic(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{})
})
}