diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eca3b89 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +ui/node_modules +ui/build \ No newline at end of file diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..3b6929c --- /dev/null +++ b/.drone-manifest.yml @@ -0,0 +1,22 @@ +image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} +{{#if build.tags}} +tags: +{{#each build.tags}} + - {{this}} +{{/each}} +{{/if}} +manifests: + - image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 + platform: + architecture: amd64 + os: linux + - image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 + platform: + architecture: arm64 + os: linux + variant: v8 + - image: registry.nemunai.re/reveil:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm + platform: + architecture: arm + os: linux + variant: v7 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..9551cff --- /dev/null +++ b/.drone.yml @@ -0,0 +1,92 @@ +--- +kind: pipeline +type: docker +name: build-arm + +platform: + os: linux + arch: arm + +workspace: + base: /go + path: src/git.nemunai.re/nemunaire/reveil + +steps: +- name: build front + image: node:18-alpine + commands: + - mkdir deploy + - cd ui + - npm install --network-timeout=100000 + - sed -i 's!@popperjs/core/dist/esm/popper!@popperjs/core!' node_modules/sveltestrap/src/*.js node_modules/sveltestrap/src/*.svelte + - npm run build + - tar chjf ../deploy/static.tar.bz2 build + +- name: build + image: golang:1-alpine + commands: + - apk --no-cache add alsa-lib-dev build-base git pkgconf + - go get -v -d + - go vet -v + - go build -v -ldflags '-w -X main.Version="${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + - ln deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} reveil + when: + event: + exclude: + - tag + +- name: build tag + image: golang:1-alpine + commands: + - apk --no-cache add alsa-lib-dev build-base git pkgconf + - go get -v -d + - go vet -v + - go build -v -ldflags '-w -X main.Version="${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + - ln deploy/reveil-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} reveil + when: + event: + - tag + +- name: docker + image: plugins/docker + settings: + registry: registry.nemunai.re + repo: registry.nemunai.re/reveil + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile-norebuild + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + event: + - cron + - push + - tag + +--- +kind: pipeline +name: docker-manifest + +steps: +- name: publish on Docker Hub + image: plugins/manifest + settings: + auto_tag: true + ignore_missing: true + spec: .drone-manifest.yml + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + event: + - cron + - push + - tag + +depends_on: +- build-arm diff --git a/.gitignore b/.gitignore index bc5efb7..878065d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ -reveil \ No newline at end of file +/actions +/alarms.db +/gongs +/reveil +/routines +/settings.json +/tracks/ +/vendor +/ui/build +/ui/node_modules \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..156c54f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:18-alpine as nodebuild + +WORKDIR /ui + +COPY ui/ . + +RUN npm install --network-timeout=100000 && \ + sed -i 's!@popperjs/core/dist/esm/popper!@popperjs/core!' node_modules/sveltestrap/src/*.js node_modules/sveltestrap/src/*.svelte && \ + npm run build + + +FROM golang:1-alpine AS build + +RUN apk --no-cache add git go-bindata + +COPY . /go/src/git.nemunai.re/nemunaire/reveil +COPY --from=nodebuild /ui/build /go/src/git.nemunai.re/nemunaire/reveil/ui/build +WORKDIR /go/src/git.nemunai.re/nemunaire/reveil +RUN go get -v && go generate -v && go build -v -ldflags="-s -w" + + +FROM alpine:3.16 + +VOLUME /data +WORKDIR /data + +EXPOSE 8080 +CMD ["/srv/reveil"] + +COPY --from=build /go/src/git.nemunai.re/nemunaire/reveil/reveil /srv/reveil diff --git a/Dockerfile-norebuild b/Dockerfile-norebuild new file mode 100644 index 0000000..9476b53 --- /dev/null +++ b/Dockerfile-norebuild @@ -0,0 +1,9 @@ +FROM alpine:3.16 + +VOLUME /data +WORKDIR /data + +EXPOSE 8080 +CMD ["/srv/reveil"] + +COPY reveil /srv/reveil diff --git a/api/actions.go b/api/actions.go new file mode 100644 index 0000000..05fe2db --- /dev/null +++ b/api/actions.go @@ -0,0 +1,93 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +func declareActionsRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/actions", func(c *gin.Context) { + actions, err := reveil.LoadActions(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, actions) + }) + router.POST("/actions", func(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusNotImplemented, gin.H{"errmsg": "TODO"}) + }) + + actionsRoutes := router.Group("/actions/:tid") + actionsRoutes.Use(func(c *gin.Context) { + actions, err := reveil.LoadActions(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + for _, t := range actions { + if t.Id.ToString() == c.Param("tid") { + c.Set("action", t) + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Action not found"}) + }) + + actionsRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("action")) + }) + actionsRoutes.PUT("", func(c *gin.Context) { + oldaction := c.MustGet("action").(*reveil.Action) + + var action reveil.Action + if err := c.ShouldBindJSON(&action); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if action.Name != oldaction.Name { + err := oldaction.Rename(action.Name) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the action: %s", err.Error())}) + return + } + } + + if action.Enabled != oldaction.Enabled { + var err error + if action.Enabled { + err = oldaction.Enable() + } else { + err = oldaction.Disable() + } + + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to enable/disable the action: %s", err.Error())}) + return + } + } + + c.JSON(http.StatusOK, oldaction) + }) + actionsRoutes.DELETE("", func(c *gin.Context) { + action := c.MustGet("action").(*reveil.Action) + + err := action.Remove() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the action: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, nil) + }) +} diff --git a/api/alarm.go b/api/alarm.go new file mode 100644 index 0000000..7b1e9d0 --- /dev/null +++ b/api/alarm.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/player" +) + +func declareAlarmRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/alarm", func(c *gin.Context) { + if player.CommonPlayer == nil { + c.JSON(http.StatusOK, false) + } else { + c.JSON(http.StatusOK, true) + } + }) + + router.POST("/alarm/run", func(c *gin.Context) { + if player.CommonPlayer == nil { + err := player.WakeUp(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + } else { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Player already running"}) + return + } + + c.JSON(http.StatusOK, true) + }) + + router.DELETE("/alarm", func(c *gin.Context) { + if player.CommonPlayer != nil { + err := player.CommonPlayer.Stop() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, true) + }) +} diff --git a/api/alarms.go b/api/alarms.go new file mode 100644 index 0000000..59dfe98 --- /dev/null +++ b/api/alarms.go @@ -0,0 +1,293 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +func declareAlarmsRoutes(cfg *config.Config, db *reveil.LevelDBStorage, resetTimer func(), router *gin.RouterGroup) { + router.GET("/alarms/next", func(c *gin.Context) { + alarm, err := reveil.GetNextAlarm(db) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, alarm) + }) + + router.GET("/alarms/single", func(c *gin.Context) { + alarms, err := reveil.GetAlarmsSingle(db) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, alarms) + }) + router.POST("/alarms/single", func(c *gin.Context) { + var alarm reveil.AlarmSingle + if err := c.ShouldBindJSON(&alarm); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if time.Now().After(alarm.Time) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "This date is already passed."}) + return + } + + alarm.Id = nil + if err := reveil.PutAlarmSingle(db, &alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, alarm) + }) + + router.GET("/alarms/repeated", func(c *gin.Context) { + alarms, err := reveil.GetAlarmsRepeated(db) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, alarms) + }) + router.POST("/alarms/repeated", func(c *gin.Context) { + var alarm reveil.AlarmRepeated + if err := c.ShouldBindJSON(&alarm); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + alarm.Id = nil + if err := reveil.PutAlarmRepeated(db, &alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, alarm) + }) + + router.GET("/alarms/exceptions", func(c *gin.Context) { + exceptions, err := reveil.GetAlarmExceptions(db) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, exceptions) + }) + router.POST("/alarms/exceptions", func(c *gin.Context) { + var alarm reveil.AlarmException + if err := c.ShouldBindJSON(&alarm); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if alarm.Start == nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Start not defined"}) + return + } + if alarm.End == nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "End not defined"}) + return + } + if time.Now().After(time.Time(*alarm.End)) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "End date is already passed."}) + return + } + if time.Time(*alarm.Start).After(time.Time(*alarm.End)) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Start is defined after End. Please verify your inputs."}) + return + } + + alarm.Id = nil + if err := reveil.PutAlarmException(db, &alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, alarm) + }) + + singleAlarmsRoutes := router.Group("/alarms/single/:aid") + singleAlarmsRoutes.Use(func(c *gin.Context) { + id, err := reveil.NewIdentifierFromString(c.Param("aid")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Invalid alarm idenfifier: %s", err.Error())}) + return + } + + alarm, err := reveil.GetAlarmSingle(db, id) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()}) + return + } + + c.Set("alarm", alarm) + + c.Next() + }) + + singleAlarmsRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("alarm")) + }) + singleAlarmsRoutes.PUT("", func(c *gin.Context) { + oldalarm := c.MustGet("alarm").(*reveil.AlarmSingle) + + var alarm reveil.AlarmSingle + if err := c.ShouldBindJSON(&alarm); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + alarm.Id = oldalarm.Id + if err := reveil.PutAlarmSingle(db, &alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, alarm) + }) + singleAlarmsRoutes.DELETE("", func(c *gin.Context) { + alarm := c.MustGet("alarm").(*reveil.AlarmSingle) + + if err := reveil.DeleteAlarmSingle(db, alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, nil) + }) + + repeatedAlarmsRoutes := router.Group("/alarms/repeated/:aid") + repeatedAlarmsRoutes.Use(func(c *gin.Context) { + id, err := reveil.NewIdentifierFromString(c.Param("aid")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Invalid alarm idenfifier: %s", err.Error())}) + return + } + + alarm, err := reveil.GetAlarmRepeated(db, id) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()}) + return + } + + c.Set("alarm", alarm) + + c.Next() + }) + + repeatedAlarmsRoutes.GET("", func(c *gin.Context) { + alarm := c.MustGet("alarm").(*reveil.AlarmRepeated) + alarm.FillExcepts(db) + alarm.NextTime = alarm.GetNextOccurence(db) + + c.JSON(http.StatusOK, alarm) + }) + repeatedAlarmsRoutes.PUT("", func(c *gin.Context) { + oldalarm := c.MustGet("alarm").(*reveil.AlarmRepeated) + + var alarm reveil.AlarmRepeated + if err := c.ShouldBindJSON(&alarm); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + alarm.Id = oldalarm.Id + if err := reveil.PutAlarmRepeated(db, &alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, alarm) + }) + repeatedAlarmsRoutes.DELETE("", func(c *gin.Context) { + alarm := c.MustGet("alarm").(*reveil.AlarmRepeated) + + if err := reveil.DeleteAlarmRepeated(db, alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, nil) + }) + + exceptionAlarmsRoutes := router.Group("/alarms/exceptions/:aid") + exceptionAlarmsRoutes.Use(func(c *gin.Context) { + id, err := reveil.NewIdentifierFromString(c.Param("aid")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Invalid alarm idenfifier: %s", err.Error())}) + return + } + + alarm, err := reveil.GetAlarmException(db, id) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": err.Error()}) + return + } + + c.Set("alarm", alarm) + + c.Next() + }) + + exceptionAlarmsRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("alarm")) + }) + exceptionAlarmsRoutes.PUT("", func(c *gin.Context) { + oldalarm := c.MustGet("alarm").(*reveil.AlarmException) + + var alarm reveil.AlarmException + if err := c.ShouldBindJSON(&alarm); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + alarm.Id = oldalarm.Id + if err := reveil.PutAlarmException(db, &alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, alarm) + }) + exceptionAlarmsRoutes.DELETE("", func(c *gin.Context) { + alarm := c.MustGet("alarm").(*reveil.AlarmException) + + if err := reveil.DeleteAlarmException(db, alarm); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + resetTimer() + + c.JSON(http.StatusOK, nil) + }) +} diff --git a/api/gongs.go b/api/gongs.go new file mode 100644 index 0000000..93aaecd --- /dev/null +++ b/api/gongs.go @@ -0,0 +1,148 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "path" + "strings" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +func declareGongsRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/gongs", func(c *gin.Context) { + gongs, err := reveil.LoadGongs(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, gongs) + }) + router.POST("/gongs", func(c *gin.Context) { + fgong, err := c.FormFile("gongfile") + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No gong found"}) + return + } + + // Check file extension + if path.Ext(fgong.Filename) != ".mp3" && path.Ext(fgong.Filename) != ".flac" && path.Ext(fgong.Filename) != ".wav" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file type. You should only upload .mp3, .wav or .flac files."}) + return + } + + if strings.Contains(fgong.Filename, "/") { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file name."}) + return + } + + dst := path.Join(cfg.GongsDir, fgong.Filename) + err = c.SaveUploadedFile(fgong, dst) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the gong: %s", err.Error())}) + return + } + + d, err := os.Stat(dst) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the gong: %s", err.Error())}) + return + } + + gong, err := reveil.LoadGong(cfg, dst, d) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to load gong: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, gong) + }) + + gongsRoutes := router.Group("/gongs/:tid") + gongsRoutes.Use(func(c *gin.Context) { + gongs, err := reveil.LoadGongs(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + for _, g := range gongs { + if g.Id.ToString() == c.Param("tid") { + c.Set("gong", g) + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Gong not found"}) + }) + + gongsRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("gong")) + }) + gongsRoutes.GET("/stream", func(c *gin.Context) { + gong := c.MustGet("gong").(*reveil.Gong) + + size, err := gong.Size() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the gong: %s", err.Error())}) + return + } + + fd, err := gong.Open() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the gong: %s", err.Error())}) + return + } + defer fd.Close() + + c.DataFromReader(http.StatusOK, size, gong.ContentType(), fd, map[string]string{}) + }) + gongsRoutes.PUT("", func(c *gin.Context) { + oldgong := c.MustGet("gong").(*reveil.Gong) + + var gong reveil.Gong + if err := c.ShouldBindJSON(&gong); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if gong.Name != oldgong.Name { + err := oldgong.Rename(gong.Name) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the gong: %s", err.Error())}) + return + } + } + + if gong.Enabled != oldgong.Enabled { + var err error + if gong.Enabled { + err = oldgong.SetDefault(cfg) + } + + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to set the new default gong: %s", err.Error())}) + return + } + } + + c.JSON(http.StatusOK, oldgong) + }) + gongsRoutes.DELETE("", func(c *gin.Context) { + gong := c.MustGet("gong").(*reveil.Gong) + + err := gong.Remove() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the gong: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, nil) + }) +} diff --git a/api/history.go b/api/history.go new file mode 100644 index 0000000..0df39bc --- /dev/null +++ b/api/history.go @@ -0,0 +1,13 @@ +package api + +import ( + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" +) + +func declareHistoryRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/stats", func(c *gin.Context) { + + }) +} diff --git a/api/quotes.go b/api/quotes.go new file mode 100644 index 0000000..508bc39 --- /dev/null +++ b/api/quotes.go @@ -0,0 +1,133 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" +) + +var quotes = []Quote{ + Quote{1, "La qualité d’un homme se calcule à sa démesure ; tentez, essayer, échouez même, ce sera votre réussite.", "Jacques brel"}, + Quote{2, "Certains veulent que ça arrive, d’autres aimeraient que ça arrive, et les autres font que ça arrive.", "Michael Jordan"}, + Quote{3, "Les winners sont des loosers qui se lèvent et essaient une fois de plus.", "Dennis DeYoung"}, + Quote{4, "L’homme le plus heureux est celui qui fait le bonheur d’un plus grand nombre d’autres.", "Denis Diderot"}, + Quote{5, "La joie est en tout ; il faut savoir l’extraire.", "Confucius"}, + Quote{6, "L’optimiste ne refuse jamais de voir le côté négatif des choses ; il refuse simplement de s’attarder dessus.", "Alexandre Lockhart"}, + Quote{7, "L’obstination est le chemin de la réussite.", "Charlie chaplin"}, + Quote{8, "Si on veut obtenir quelque chose que l’on n’a jamais eu, il faut tenter quelque chose que l’on n’a jamais fait.", "Péricles"}, + Quote{9, "La plus grande erreur que puisse faire un homme est d’avoir peur d’en faire une.", "Elbert Hubbard"}, + Quote{10, "La définition de la folie, c’est de refaire toujours la même chose, et d’attendre un résultat différent.", "Albert Einstein"}, + Quote{11, "Peu importe qui tu es ou qui tu as été, tu peux être qui tu veux.", "Clement Stone"}, + Quote{12, "Celui qui attend que tout danger soir écarté pour mettre les voiles ne prendra jamais la mer.", "Thomas Fuller"}, + Quote{13, "Beaucoup de ceux qui ont échoué n’ont pas réalisé qu’ils étaient aussi près du succès quand ils ont abandonné.", "Thomas Edison"}, + Quote{14, "La vie, c’est comme une bicyclette, il faut avancer pour ne pas perdre l’équilibre.", "Albert Einstein"}, + Quote{15, "Vous ne pouvez choisir ni comment mourir, ni quand. Mais vous pouvez décider de comment vous allez vivre. Maintenant.", "Joan Baez"}, + Quote{16, "Je passe mon temps à faire ce que je ne sais pas faire, pour apprendre à le faire.", "Pablo Picasso"}, + Quote{17, "Le dictionnaire, c’est le seul endroit où Succès arrive avant Travail.", "Vince Lombardi"}, + Quote{18, "Si on me donnait six heures pour abattre un arbre, je passerais la première à affûter la hache.", "Abraham Lincoln"}, + Quote{19, "A la fin, ce qui compte, ce ne sont pas les années qu’il y a eu dans la vie. C’est la vie qu’il y a eu dans les années.", "Abraham Lincoln"}, + Quote{20, "La logique peut vous mener d’un point A à un point B. L’imagination peut vous mener partout.", "Albert Einstein"}, + Quote{21, "N’allez pas où va le chemin. Allez là où il n’y en a pas encore, et ouvrez la route.", "Ralph Waldo Emerson"}, + Quote{22, "Un homme doit être assez grand pour admettre ses erreurs, assez intelligent pour apprendre de celles-ci et assez fort pour les corriger. John C.", "Maxwell"}, + Quote{23, "Si ce que vous faites ne vous rapproche pas de vos buts, alors c’est que ça vous éloigne de ceux-ci.", "Brian Tracy"}, + Quote{24, "Qui veut faire quelque chose trouve un moyen, qui ne veut rien faire trouve une excuse.", "Proverbe arabe"}, + Quote{25, "Si vous voulez que la vie vous sourie, apportez-lui votre bonne humeur.", "Baruch Spinoza"}, + Quote{26, "Nulle pierre ne peut être polie sans friction, nul homme ne peut parfaire son expérience sans épreuve.", "Confucius"}, + Quote{27, "Se donner du mal pour les petites choses, c’est parvenir aux grandes, avec le temps.", "Samuel Beckett"}, + Quote{28, "Le succès c’est d’avoir ce que vous désirez. Le bonheur c’est aimer ce que vous avez. H.", "Jackson Brown"}, + Quote{29, "La chose la plus difficile est de n’attribuer aucune importance aux choses qui n’ont aucune importance.", "Charles de Gaulle"}, + Quote{30, "Nous commençons à vieillir quand nous remplaçons nos rêves par des regrets.", "Sénèque"}, + Quote{31, "Je préfère vivre en optimiste et me tromper, que vivre en pessimiste pour la seule satisfaction d’avoir eu raison.", "Milan Kundera"}, + Quote{32, "Les optimistes proclament que nous vivons dans un monde rempli de possibilités… Les pessimistes ont peur que ce soit vrai !", "James Branch Cabell"}, + Quote{33, "L’une des meilleures façons d’aider quelqu’un est de lui donner une responsabilité et de lui faire savoir que vous lui faites confiance.", "Booker Washington"}, + Quote{34, "La seule limite à notre épanouissement de demain sera nos doutes d’aujourd’hui.", "Franklin Roosevelt"}, + Quote{35, "C’est dans les moments les plus sombres qu’on voit le mieux les étoiles.", "Charles Beard"}, + Quote{36, "Ne jugez pas chaque journée par votre récolte, mais par les graines que vous avez plantées.", "Robert Stevenson"}, + Quote{37, "Contentez-vous d’agir et laissez les autres parler.", "Baltasar Gracian"}, + Quote{38, "Mettez en tout un grain d’audace.", "Baltazar Gracian"}, + Quote{39, "Un sourire coûte moins cher que l’électricité, mais donne autant de lumière.", "Abbé Pierre"}, + Quote{40, "N’attendez pas d’être heureux pour sourire, souriez pour être heureux.", "Edward Kramer"}, + Quote{41, "Tout le monde est un génie. Mais si on juge un poisson sur sa capacité à grimper à un arbre, il passera sa vie à croire qu’il est stupide.", "Albert Einstein"}, + Quote{42, "Le succès est la capacité d’aller d’échec en échec sans perdre son enthousiasme.", "Winston Churchill"}, + Quote{43, "Rien ne sert de défendre le monde d’hier quand on peut construire le monde de demain.", "Peter Drucker"}, + Quote{44, "Le plus grand plaisir de la vie est de réaliser ce que les autres vous pensent incapable de réaliser.", "Walter Bagehot"}, + Quote{45, "Nous avons deux choix dans la vie : le premier est d’accepter les choses comme elles sont et la deuxième est de prendre la décision de les changer.", "Denis Waitley"}, + Quote{46, "N’acceptez jamais la défaite, vous êtes peut-être à un pas de la réussite.", "Jack E Addington"}, + Quote{47, "L’échec est seulement l’opportunité de recommencer d’une façon plus intelligente.", "Henry Ford"}, + Quote{48, "Il n’y a qu’une façon d’échouer, c’est d’abandonner avant d’avoir réussi.", "Georges Clemenceau"}, + Quote{49, "Vous n’avez rien à craindre car l’échec est impossible. Vous ne pouvez qu’apprendre, évoluer et devenir meilleur que vous ne l’avez jamais été.", "Hal Elrod"}, + Quote{50, "Appréciez d’échouer, et apprenez de l’échec, car on n’apprend rien de ses succès.", "James Dyson"}, + Quote{51, "Si vous vivez un moment difficile, ne blâmez pas la vie. Vous êtes juste en train de devenir plus fort.", "Gandhi"}, + Quote{52, "Au milieu de toute difficulté se trouve cachée une opportunité.", "Albert Einstein"}, + Quote{53, "L’échec est l’épice qui donne sa saveur au succès.", "Truman Capote"}, + Quote{54, "Je n’ai pas échoué. J’ai simplement trouvé 10 000 façons de ne pas y arriver.", "Thomas Edison"}, + Quote{55, "Les plus belles années d’une vie sont celles que l’on n’a pas encore vécues.", "Victor Hugo"}, + Quote{56, "Si vous voulez que la vie vous sourie, apportez-lui d’abord votre bonne humeur.", "Baruch Spinoza"}, + Quote{57, "Ce n’est pas parce que les choses sont difficiles que nous n’osons pas, c’est parce que nous n’osons pas qu’elles sont difficiles.", "Sénèque"}, + Quote{58, "Si on veut obtenir quelque chose que l’on n’a jamais eu, il faut tenter quelque chose que l’on n’a jamais fait.", "Périclès"}, + Quote{59, "Accepte ce qui est, laisse aller ce qui était, aie confiance en ce qui sera.", "Bouddha"}, + Quote{60, "Choisis un travail que tu aimes, tu n’auras pas à travailler un seul jour de ta vie.", "Confucius"}, + Quote{61, "Il est de loin plus lucratif et plus amusant de capitaliser sur vos points forts que d’essayer de corriger tous vos points faibles.", "Tim Ferriss"}, + Quote{62, "Un homme ayant du succès est celui pouvant se construire une ferme fondation avec les briques que les autres lui jettent.", "David Brinkley"}, + Quote{63, "Ils ne savaient pas que c’était impossible alors ils l’ont fait.", "Mark Twain"}, + Quote{64, "Les gagnants trouvent des moyens, les perdants des excuses.", "Franklin Roosevelt"}, + Quote{65, "Croyez en vos rêves et ils se réaliseront peut-être. Croyez en vous et ils se réaliseront sûrement.", "Martin Luther King"}, + Quote{66, "Un voyage de mille lieues commence toujours par un premier pas.", "Lao Tseu"}, + Quote{67, "Il faut toujours viser la lune car même en cas d’échec on atterrit dans les étoiles.", "Oscar Wilde"}, + Quote{68, "Ce qui est plus triste qu’une œuvre inachevée, c’est une œuvre jamais commencée.", "Christinna Rosseti"}, + Quote{69, "L’obscurité ne peut pas chasser l’obscurité, seule la lumière le peut. La haine ne peut pas chasser la haine, seul l’amour le peut.", "Martin Luther King"}, + Quote{70, "Je n’ai pas peur de demain, car j’ai vu hier et j’aime aujourd’hui.", "William Allen White"}, + Quote{71, "Un pessimiste voit la difficulté dans chaque opportunité, un optimiste voit l’opportunité dans chaque difficulté.", "Winston Churchill"}, + Quote{72, "Si vous regardez avec attention, la plupart des succès obtenus du jour au lendemain prennent beaucoup de temps.", "Steve Jobs"}, + Quote{73, "Les échecs sont les marches que nous montons pour atteindre le succès.", "Roy Bennett"}, + Quote{74, "Si vous pouvez le rêver, vous pouvez le faire.", "Walt Disney"}, + Quote{75, "Croyez en vos rêves et ils se réaliseront peut-être. Croyez en vous, et ils se réaliseront sûrement.", "Martin Luther King"}, + Quote{76, "La première étape est de dire que tu peux.", "Will Smith"}, + Quote{77, "Quand on veut une chose, tout l’Univers conspire à nous permettre de réaliser notre rêve.", "Paulo Coelho"}, + Quote{78, "Tout est possible à qui rêve, ose, travaille et n’abandonne jamais.", "Xavier Dolan"}, + Quote{79, "La sagesse, c’est d’avoir des rêves suffisamment grands pour ne pas les perdre de vue lorsqu’on les poursuit.", "Oscar Wilde"}, + Quote{80, "Quand vous osez rêver grand, c’est là où votre système nerveux va créer du plaisir et être orienté solutions.", "David Laroche"}, + Quote{81, "Choisir sa vie, c’est se demander si l’on doit réaliser ses rêves ou subir le quotidien.", "Sonia Lahsaini"}, + Quote{82, "Fais de ta vie un rêve, et d’un rêve, une réalité.", "Antoine de Saint-Exupéry"}, +} + +func declareQuotesRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/quoteoftheday", func(c *gin.Context) { + c.JSON(http.StatusOK, quotes[time.Now().Add(-5*time.Hour).YearDay()%len(quotes)]) + }) + + router.GET("/quotes", func(c *gin.Context) { + c.JSON(http.StatusOK, quotes) + }) + router.POST("/quotes", func(c *gin.Context) { + + }) + + quotesRoutes := router.Group("/quotes/:qid") + quotesRoutes.Use(quoteHandler) + + quotesRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("quote")) + }) + quotesRoutes.PUT("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("quote")) + }) + quotesRoutes.DELETE("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("quote")) + }) +} + +func quoteHandler(c *gin.Context) { + c.Set("quote", nil) + + c.Next() +} + +type Quote struct { + Id int `json:"id"` + Content string `json:"content"` + Author string `json:"author"` +} diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..38b3f40 --- /dev/null +++ b/api/routes.go @@ -0,0 +1,22 @@ +package api + +import ( + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +func DeclareRoutes(router *gin.Engine, cfg *config.Config, db *reveil.LevelDBStorage, resetTimer func()) { + apiRoutes := router.Group("/api") + + declareActionsRoutes(cfg, apiRoutes) + declareAlarmRoutes(cfg, apiRoutes) + declareAlarmsRoutes(cfg, db, resetTimer, apiRoutes) + declareGongsRoutes(cfg, apiRoutes) + declareHistoryRoutes(cfg, apiRoutes) + declareQuotesRoutes(cfg, apiRoutes) + declareRoutinesRoutes(cfg, apiRoutes) + declareTracksRoutes(cfg, apiRoutes) + declareSettingsRoutes(cfg, apiRoutes) +} diff --git a/api/routines.go b/api/routines.go new file mode 100644 index 0000000..16d7c0a --- /dev/null +++ b/api/routines.go @@ -0,0 +1,81 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +func declareRoutinesRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/routines", func(c *gin.Context) { + routines, err := reveil.LoadRoutines(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, routines) + }) + router.POST("/routines", func(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusNotImplemented, gin.H{"errmsg": "TODO"}) + }) + + routinesRoutes := router.Group("/routines/:tid") + routinesRoutes.Use(func(c *gin.Context) { + routines, err := reveil.LoadRoutines(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + for _, t := range routines { + if t.Id.ToString() == c.Param("tid") { + c.Set("routine", t) + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Routine not found"}) + }) + + routinesRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("routine")) + }) + routinesRoutes.PUT("", func(c *gin.Context) { + oldroutine := c.MustGet("routine").(*reveil.Routine) + + var routine reveil.Routine + if err := c.ShouldBindJSON(&routine); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if routine.Name != oldroutine.Name { + err := oldroutine.Rename(routine.Name) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the routine: %s", err.Error())}) + return + } + } + + // TODO: change actions + + c.JSON(http.StatusOK, oldroutine) + }) + routinesRoutes.DELETE("", func(c *gin.Context) { + routine := c.MustGet("routine").(*reveil.Routine) + + err := routine.Remove() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the routine: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, nil) + }) +} diff --git a/api/settings.go b/api/settings.go new file mode 100644 index 0000000..7bc0a58 --- /dev/null +++ b/api/settings.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +func declareSettingsRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/settings", func(c *gin.Context) { + settings, err := reveil.ReadSettings(cfg.SettingsFile) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, settings) + }) + router.PUT("/settings", func(c *gin.Context) { + var config reveil.Settings + err := c.ShouldBindJSON(&config) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + err = reveil.SaveSettings(cfg.SettingsFile, config) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, config) + }) +} diff --git a/api/tracks.go b/api/tracks.go new file mode 100644 index 0000000..39ba861 --- /dev/null +++ b/api/tracks.go @@ -0,0 +1,150 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "path" + "strings" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +func declareTracksRoutes(cfg *config.Config, router *gin.RouterGroup) { + router.GET("/tracks", func(c *gin.Context) { + tracks, err := reveil.LoadTracks(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, tracks) + }) + router.POST("/tracks", func(c *gin.Context) { + ftrack, err := c.FormFile("trackfile") + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No track found"}) + return + } + + // Check file extension + if path.Ext(ftrack.Filename) != ".mp3" && path.Ext(ftrack.Filename) != ".flac" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file type. You should only upload .mp3 or .flac files."}) + return + } + + if strings.Contains(ftrack.Filename, "/") { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad file name."}) + return + } + + dst := path.Join(cfg.TracksDir, ftrack.Filename) + err = c.SaveUploadedFile(ftrack, dst) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the track: %s", err.Error())}) + return + } + + d, err := os.Stat(dst) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when saving the track: %s", err.Error())}) + return + } + + track, err := reveil.LoadTrack(dst, d) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to load track: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, track) + }) + + tracksRoutes := router.Group("/tracks/:tid") + tracksRoutes.Use(func(c *gin.Context) { + tracks, err := reveil.LoadTracks(cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + for _, t := range tracks { + if t.Id.ToString() == c.Param("tid") { + c.Set("track", t) + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Track not found"}) + }) + + tracksRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("track")) + }) + tracksRoutes.GET("/stream", func(c *gin.Context) { + track := c.MustGet("track").(*reveil.Track) + + size, err := track.Size() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the track: %s", err.Error())}) + return + } + + fd, err := track.Open() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to open the track: %s", err.Error())}) + return + } + defer fd.Close() + + c.DataFromReader(http.StatusOK, size, track.ContentType(), fd, map[string]string{}) + }) + tracksRoutes.PUT("", func(c *gin.Context) { + oldtrack := c.MustGet("track").(*reveil.Track) + + var track reveil.Track + if err := c.ShouldBindJSON(&track); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if track.Name != oldtrack.Name { + err := oldtrack.Rename(track.Name) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to rename the track: %s", err.Error())}) + return + } + } + + if track.Enabled != oldtrack.Enabled { + var err error + if track.Enabled { + err = oldtrack.Enable(cfg) + } else { + err = oldtrack.Disable() + } + + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to enable/disable the track: %s", err.Error())}) + return + } + } + + c.JSON(http.StatusOK, oldtrack) + }) + tracksRoutes.DELETE("", func(c *gin.Context) { + track := c.MustGet("track").(*reveil.Track) + + err := track.Remove() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the track: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, nil) + }) +} diff --git a/app.go b/app.go new file mode 100644 index 0000000..778abd6 --- /dev/null +++ b/app.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/api" + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" + "git.nemunai.re/nemunaire/reveil/player" + "git.nemunai.re/nemunaire/reveil/ui" +) + +type App struct { + cfg *config.Config + db *reveil.LevelDBStorage + router *gin.Engine + srv *http.Server + nextAlarm *time.Timer +} + +func NewApp(cfg *config.Config) *App { + if cfg.DevProxy == "" { + gin.SetMode(gin.ReleaseMode) + } + gin.ForceConsoleColor() + router := gin.Default() + + router.Use(func(c *gin.Context) { + c.Next() + }) + + // Open Database + db, err := reveil.NewLevelDBStorage(cfg.LevelDBPath) + if err != nil { + log.Fatal("Unable to open the database:", err) + } + + // Prepare struct + app := &App{ + cfg: cfg, + db: db, + router: router, + } + + // Register routes + ui.DeclareRoutes(router, cfg) + api.DeclareRoutes(router, cfg, db, app.ResetTimer) + + router.GET("/api/version", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"version": Version}) + }) + + return app +} + +func (app *App) Start() { + app.srv = &http.Server{ + Addr: app.cfg.Bind, + Handler: app.router, + } + + app.ResetTimer() + + log.Printf("Ready, listening on %s\n", app.cfg.Bind) + if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } +} + +func (app *App) ResetTimer() { + if app.nextAlarm != nil { + app.nextAlarm.Stop() + app.nextAlarm = nil + } + + if na, err := reveil.GetNextAlarm(app.db); err == nil && na != nil { + app.nextAlarm = time.AfterFunc(time.Until(*na), func() { + app.nextAlarm = nil + reveil.RemoveOldAlarmsSingle(app.db) + err := player.WakeUp(app.cfg) + if err != nil { + log.Println(err.Error()) + return + } + }) + log.Println("Next timer programmed for", *na) + } +} + +func (app *App) Stop() { + if app.nextAlarm != nil { + app.nextAlarm.Stop() + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := app.srv.Shutdown(ctx); err != nil { + log.Fatal("Server Shutdown:", err) + } + + if err := app.db.Close(); err != nil { + log.Fatal("Database Close:", err) + } +} diff --git a/config/cli.go b/config/cli.go new file mode 100644 index 0000000..1894daa --- /dev/null +++ b/config/cli.go @@ -0,0 +1,59 @@ +package config + +import ( + "flag" +) + +// declareFlags registers flags for the structure Options. +func (c *Config) declareFlags() { + flag.Var(&c.ExternalURL, "external-url", "Public URL of the service") + flag.StringVar(&c.BaseURL, "baseurl", c.BaseURL, "URL prepended to each URL") + flag.StringVar(&c.Bind, "bind", c.Bind, "Bind port/socket") + flag.StringVar(&c.DevProxy, "dev", c.DevProxy, "Use ui directory instead of embedded assets") + flag.StringVar(&c.LevelDBPath, "leveldb-path", c.LevelDBPath, "Path to the LevelDB database") + flag.StringVar(&c.SettingsFile, "settings-file", c.SettingsFile, "Path to the file containing the settings") + flag.StringVar(&c.TracksDir, "tracks-dir", c.TracksDir, "Path to the directory containing the tracks") + flag.StringVar(&c.GongsDir, "gongs-dir", c.GongsDir, "Path to the directory containing the gongs") + flag.StringVar(&c.ActionsDir, "actions-dir", c.ActionsDir, "Path to the directory containing the actions") + flag.StringVar(&c.RoutinesDir, "routines-dir", c.RoutinesDir, "Path to the directory containing the routines") + flag.IntVar(&c.SampleRate, "samplerate", c.SampleRate, "Samplerate for unifying output stream") + + // Others flags are declared in some other files when they need specials configurations +} + +func Consolidated() (cfg *Config, err error) { + // Define defaults options + cfg = &Config{ + Bind: "127.0.0.1:8080", + LevelDBPath: "alarms.db", + SettingsFile: "./settings.json", + TracksDir: "./tracks/", + GongsDir: "./gongs/", + ActionsDir: "./actions/", + RoutinesDir: "./routines/", + SampleRate: 44100, + } + + cfg.declareFlags() + + // Then, overwrite that by what is present in the environment + err = cfg.FromEnv() + if err != nil { + return + } + + // Finaly, command line takes precedence + err = cfg.parseCLI() + if err != nil { + return + } + + return +} + +// parseCLI parse the flags and treats extra args as configuration filename. +func (c *Config) parseCLI() error { + flag.Parse() + + return nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..26fe5c1 --- /dev/null +++ b/config/config.go @@ -0,0 +1,38 @@ +package config + +import ( + "flag" + "strings" +) + +type Config struct { + DevProxy string + Bind string + ExternalURL URL + BaseURL string + + LevelDBPath string + SettingsFile string + TracksDir string + GongsDir string + ActionsDir string + RoutinesDir string + + SampleRate int +} + +// parseLine treats a config line and place the read value in the variable +// declared to the corresponding flag. +func (c *Config) parseLine(line string) (err error) { + fields := strings.SplitN(line, "=", 2) + orig_key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + + key := strings.TrimPrefix(orig_key, "REVEIL_") + key = strings.Replace(key, "_", "-", -1) + key = strings.ToLower(key) + + err = flag.Set(key, value) + + return +} diff --git a/config/custom.go b/config/custom.go new file mode 100644 index 0000000..716a2f7 --- /dev/null +++ b/config/custom.go @@ -0,0 +1,44 @@ +package config + +import ( + "encoding/base64" + "net/url" +) + +type JWTSecretKey []byte + +func (i *JWTSecretKey) String() string { + return base64.StdEncoding.EncodeToString(*i) +} + +func (i *JWTSecretKey) Set(value string) error { + z, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return err + } + + *i = z + return nil +} + +type URL struct { + URL *url.URL +} + +func (i *URL) String() string { + if i.URL != nil { + return i.URL.String() + } else { + return "" + } +} + +func (i *URL) Set(value string) error { + u, err := url.Parse(value) + if err != nil { + return err + } + + i.URL = u + return nil +} diff --git a/config/env.go b/config/env.go new file mode 100644 index 0000000..ae852a3 --- /dev/null +++ b/config/env.go @@ -0,0 +1,21 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +// FromEnv analyzes all the environment variables to find each one +// starting by GUSTUS_ +func (c *Config) FromEnv() error { + for _, line := range os.Environ() { + if strings.HasPrefix(line, "GUSTUS_") { + err := c.parseLine(line) + if err != nil { + return fmt.Errorf("error in environment (%q): %w", line, err) + } + } + } + return nil +} diff --git a/go.mod b/go.mod index cf2621a..23551b2 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,40 @@ -module git.nemunai.re/reveil +module git.nemunai.re/nemunaire/reveil -go 1.16 +go 1.18 -require github.com/faiface/beep v1.0.3-0.20210301102329-98afada94bff +require ( + github.com/faiface/beep v0.0.0-00010101000000-000000000000 + github.com/gin-gonic/gin v1.8.1 + github.com/syndtr/goleveldb v1.0.0 +) + +require ( + github.com/ebitengine/purego v0.0.0-20220907032450-cf3e27c364c7 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/go-playground/validator/v10 v10.10.0 // indirect + github.com/goccy/go-json v0.9.7 // indirect + github.com/golang/snappy v0.0.1 // indirect + github.com/hajimehoshi/go-mp3 v0.3.0 // indirect + github.com/hajimehoshi/oto/v2 v2.4.0-alpha.4 // indirect + github.com/icza/bitio v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mewkiz/flac v1.0.7 // indirect + github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/ugorji/go/codec v1.2.7 // indirect + golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect + golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect + golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e // indirect + golang.org/x/text v0.3.7 // indirect + google.golang.org/protobuf v1.28.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) + +replace github.com/faiface/beep => github.com/MarkKremer/beep v1.0.3-0.20221013180303-756ceb286755 diff --git a/go.sum b/go.sum index 190f64d..9d932f0 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,152 @@ github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/MarkKremer/beep v1.0.3-0.20221013180303-756ceb286755 h1:rkuKNEd+Izze/hA44R1kzPs9BXa544xXeufys8x0fkc= +github.com/MarkKremer/beep v1.0.3-0.20221013180303-756ceb286755/go.mod h1:PWWzyIlbyHQjQ/gJzGiMyDAvjo/t5L8TC8qkyX8UfWs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= -github.com/faiface/beep v1.0.3-0.20210301102329-98afada94bff h1:4zdNP0II42+Z9XaMdFZms8q0NdGGB6cQi1b9zPntdcY= -github.com/faiface/beep v1.0.3-0.20210301102329-98afada94bff/go.mod h1:fQeOQNj1CiI7p0IeJ159hm16XDejdTP3B92ngeJMxac= +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/ebitengine/purego v0.0.0-20220907032450-cf3e27c364c7 h1:tmSauY5l3s/Cp5n+cEiG1epUR2AejmdHeMJMycMFxb0= +github.com/ebitengine/purego v0.0.0-20220907032450-cf3e27c364c7/go.mod h1:Eh8I3yvknDYZeCuXH9kRNaPuHEwvXDCk378o9xszmHg= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8= +github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0= +github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= +github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM= +github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/hajimehoshi/go-mp3 v0.3.0 h1:fTM5DXjp/DL2G74HHAs/aBGiS9Tg7wnp+jkU38bHy4g= github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= -github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk= -github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= +github.com/hajimehoshi/oto/v2 v2.4.0-alpha.4 h1:m29xzbn3Pv5MgvgjMPs7m28uhUgVt3B3AIGjQLgkqUI= +github.com/hajimehoshi/oto/v2 v2.4.0-alpha.4/go.mod h1:OdGUICBjy7upAjvqqacbB63XIuYR3fqXZ7kYtlVYJgQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8= github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mewkiz/flac v1.0.6 h1:OnMwCWZPAnjDndjEzLynOZ71Y2U+/QYHoVI4JEKgKkk= -github.com/mewkiz/flac v1.0.6/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= +github.com/mewkiz/flac v1.0.7 h1:uIXEjnuXqdRaZttmSFM5v5Ukp4U6orrZsnYGGR3yow8= +github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 h1:EyTNMdePWaoWsRSGQnXiSoQu0r6RS1eA557AwJhlzHU= github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU= +github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg= +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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e h1:NHvCuwuS43lGnYhten69ZWqi2QOj/CiDNcKbVqwVoew= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index c7c9a94..812adbf 100644 --- a/main.go +++ b/main.go @@ -1,339 +1,65 @@ package main import ( - "bytes" - "flag" - "fmt" "log" - "math" - "math/rand" "os" - "os/exec" "os/signal" - "path" - "strconv" - "strings" "syscall" - "time" - "github.com/faiface/beep" - "github.com/faiface/beep/effects" - "github.com/faiface/beep/flac" - "github.com/faiface/beep/mp3" - "github.com/faiface/beep/speaker" - "github.com/faiface/beep/wav" + "git.nemunai.re/nemunaire/reveil/config" ) var ( - MaxRunTime = 1 * time.Hour - ntick int64 = 0 + Version = "custom-build" ) -func xPrintIdle() (idle uint64) { - cmd := exec.Command("xprintidle") - cmd.Env = append(os.Environ(), "DISPLAY=:0") - var out bytes.Buffer - cmd.Stdout = &out - if err := cmd.Run(); err != nil { - log.Println(err) - } else { - s := string(out.Bytes()) - if idle, err = strconv.ParseUint(strings.TrimSpace(s), 10, 64); err != nil { - log.Println(err) - } - } - return -} - -func speakToday() { - cmdSetVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", fmt.Sprintf("%d%%", 50+50/(int64(MaxRunTime.Seconds()/3)/ntick+1))) - if err := cmdSetVolume.Run(); err != nil { - log.Println(err) - } - - cmd0 := exec.Command("/home/nemunaire/scripts/wakeup/today.sh") - if err := cmd0.Run(); err != nil { - log.Println(err) - } - - cmdSetBackVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", "100%") - if err := cmdSetBackVolume.Run(); err != nil { - log.Println(err) - } -} - -func speakWeather() { - var icon = "partly-cloudy-day" - - cmd := exec.Command("/home/nemunaire/scripts/wakeup/ind_weather.sh") - var out bytes.Buffer - cmd.Stdout = &out - if err := cmd.Run(); err != nil { - log.Println(err) - } else { - icon = string(out.Bytes()) - } - - var preset = "pika-summer-forest.xml" - switch icon { - case "clear-day", "clear-night": - preset = "grassland.xml" - case "rain", "hail": - preset = "the-perfect-storm.xml" - case "snow", "sleet": - preset = "a-walk-in-the-cold.xml" - case "wind", "tornado": - preset = "desert-wind.xml" - case "fog": - preset = "silent-hill-fog-world.xml" - case "cloudy": - preset = "autumn-forest.xml" - case "thunderstorm": - preset = "heavy-thunderstorm-for-me.xml" - default: - preset = "autumn-forest.xml" - } - - cmdAmbiant := exec.Command("python3", "ambient.py", path.Join("presets", preset)) - cmdAmbiant.Dir = "/home/nemunaire/workspace/pyambientmixer" - if err := cmdAmbiant.Start(); err != nil { - log.Println(err) - } - - cmdSetVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", fmt.Sprintf("%d%%", 50+50/(int64(MaxRunTime.Seconds()/3)/ntick+1))) - if err := cmdSetVolume.Run(); err != nil { - log.Println(err) - } - - cmd0 := exec.Command("/home/nemunaire/scripts/wakeup/today.sh") - if err := cmd0.Run(); err != nil { - log.Println(err) - } - - cmd1 := exec.Command("/home/nemunaire/scripts/wakeup/weather.sh") - if err := cmd1.Run(); err != nil { - log.Println(err) - } - - cmd2 := exec.Command("/home/nemunaire/scripts/wakeup/airparif.sh") - if err := cmd2.Run(); err != nil { - log.Println(err) - } - - cmd3 := exec.Command("/home/nemunaire/scripts/wakeup/ratp-traffic.sh", "rers", "B") - if err := cmd3.Run(); err != nil { - log.Println(err) - } - - if cmdAmbiant.Process != nil { - (*cmdAmbiant.Process).Kill() - } - cmdAmbiant.Process.Wait() - - cmdSetBackVolume := exec.Command("amixer", "-D", "pulse", "set", "Master", "100%") - if err := cmdSetBackVolume.Run(); err != nil { - log.Println(err) - } -} - -func loadFile(filepath string) (name string, s beep.StreamSeekCloser, format beep.Format, err error) { - var fd *os.File - - name = path.Base(filepath) - - fd, err = os.Open(filepath) - if err != nil { - return - } - - switch strings.ToLower(path.Ext(filepath)) { - case ".flac": - s, format, err = flac.Decode(fd) - case ".mp3": - s, format, err = mp3.Decode(fd) - default: - s, format, err = wav.Decode(fd) - } - - if err != nil { - fd.Close() - return - } - - return - -} - func main() { - var weatherTime = flag.Duration("weather", -1, "Speak weather?") - var noshuffle = flag.Bool("noshuffle", false, "Don't shuffle music order") - var ignoreidle = flag.Bool("ignoreidle", false, "Don't stop the reveil on idle detection change") - var sr = flag.Int("samplerate", 44100, "Samplerate for unifying output stream") - var claironTime = flag.Duration("clairon", -1, "Time before running the wake up clairon song") - flag.DurationVar(&MaxRunTime, "maxruntime", MaxRunTime, "Maximum duration before auto exit") - flag.Parse() - - if len(flag.Args()) < 1 { - log.Println("missing required argument: input file name") - return + cfg, err := config.Consolidated() + if err != nil { + log.Fatal("Unable to read configuration:", err) } - seed := time.Now().Unix() - seed -= seed % 172800 - - reverseOrder := int(time.Now().Unix()/86400)%2 == 0 - - log.Println("Starting reveil with seed:", seed, "; order:", reverseOrder) - - rand.Seed(seed) - - sampleRate := beep.SampleRate(*sr) - - paths := []string{} - playlist := []beep.Streamer{} - formats := []beep.Format{} - - // Load playlist - log.Println("Loading playlist...") - for _, arg := range flag.Args() { - p, s, f, err := loadFile(arg) + // Clean paths + if _, err := os.Stat(cfg.SettingsFile); os.IsNotExist(err) { + fd, err := os.Create(cfg.SettingsFile) if err != nil { - log.Printf("Unable to load %s: %s", arg, err) - continue + log.Fatal("Unable to create settings file:", err) } - paths = append(paths, p) - playlist = append(playlist, s) - formats = append(formats, f) + + fd.Write([]byte{'{', '}'}) + + fd.Close() } - if noshuffle == nil || *noshuffle == false { - log.Println("Shuffling playlist...") - // Shuffle the playlist - rand.Shuffle(len(playlist), func(i, j int) { - paths[i], paths[j] = paths[j], paths[i] - playlist[i], playlist[j] = playlist[j], playlist[i] - formats[i], formats[j] = formats[j], formats[i] - }) + if _, err := os.Stat(cfg.TracksDir); os.IsNotExist(err) { + if err := os.Mkdir(cfg.TracksDir, 0755); err != nil { + log.Fatal("Unable to create tracks directory:", err) + } } - log.Println("Playlist in use:", strings.Join(paths, " ; ")) - - var launched time.Time - var volume *effects.Volume - - dontUpdateVolume := false - hasClaironed := claironTime == nil || *claironTime == -1 - hasSpokeWeather := weatherTime == nil || *weatherTime == -1 - playedItem := -1 - - // Create infinite stream - stream := beep.Iterate(func() beep.Streamer { - if !hasClaironed && time.Since(launched) >= *claironTime { - log.Println("clairon time!") - *claironTime += *claironTime / 2 - //_, sample, format, err := loadFile("/home/nemunaire/www/audio/miracle-morning/clairon-reveil.mp3") - //_, sample, format, err := loadFile("/home/nemunaire/www/audio/miracle-morning/coq.flac") - _, sample, format, err := loadFile("/home/nemunaire/www/audio/miracle-morning/NukeAnthem.flac") - if err == nil { - volume.Volume = 0.1 - dontUpdateVolume = true - if format.SampleRate != sampleRate { - return beep.Resample(3, format.SampleRate, sampleRate, sample) - } else { - return sample - } - } else { - log.Println("Error loading clairon:", err) - } + if _, err := os.Stat(cfg.GongsDir); os.IsNotExist(err) { + if err := os.Mkdir(cfg.GongsDir, 0755); err != nil { + log.Fatal("Unable to create gongs directory:", err) } - - if !hasSpokeWeather && time.Since(launched) >= *weatherTime { - log.Println("weather time!") - hasSpokeWeather = true - return beep.Callback(speakWeather) + } + if _, err := os.Stat(cfg.ActionsDir); os.IsNotExist(err) { + if err := os.Mkdir(cfg.ActionsDir, 0755); err != nil { + log.Fatal("Unable to create actions directory:", err) } - - dontUpdateVolume = false - volume.Volume = -2 - math.Log(5/float64(ntick))/3 - - if reverseOrder { - playedItem -= 1 - } else { - playedItem += 1 - } - - if playedItem >= len(playlist) { - playedItem = 0 - } else if playedItem < 0 { - playedItem = len(playlist) - 1 - } - - if i, ok := playlist[playedItem].(beep.StreamSeekCloser); ok { - // In case of loop, ensure we are at the beginning of the stream - i.Seek(0) - } - - // Resample if needed - log.Println("playing list item:", playedItem, "/", len(playlist)) - if formats[playedItem].SampleRate != sampleRate { - return beep.Resample(3, formats[playedItem].SampleRate, sampleRate, playlist[playedItem]) - } else { - return playlist[playedItem] - } - }) - - // Prepare sound player - log.Println("Initializing sound player...") - speaker.Init(sampleRate, sampleRate.N(time.Second/10)) - - volume = &effects.Volume{stream, 10, -2, false} - speaker.Play(volume) - - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - launched = time.Now() - idle := xPrintIdle() - - // Prepare graceful shutdown - maxRun := time.After(MaxRunTime) - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) - -loop: - for { - select { - case <-maxRun: - break loop - case <-ticker.C: - ntick += 1 - if !dontUpdateVolume { - volume.Volume = -2 - math.Log(5/float64(ntick))/3 - } - - if ignoreidle == nil || !*ignoreidle { - if idle < 60000 { - idle = xPrintIdle() - } else if xPrintIdle() < idle { - break loop - } - } - case <-interrupt: - break loop + } + if _, err := os.Stat(cfg.RoutinesDir); os.IsNotExist(err) { + if err := os.Mkdir(cfg.RoutinesDir, 0755); err != nil { + log.Fatal("Unable to create routines directory:", err) } } - // Tell parent process that it can launch the wake up procedure - if time.Since(launched) < MaxRunTime && time.Since(launched) > 60*time.Second { - if proc, err := os.FindProcess(os.Getppid()); err != nil { - log.Println(err) - } else if err := proc.Signal(syscall.SIGHUP); err != nil { - log.Println(err) - } - } + // Start app + a := NewApp(cfg) + go a.Start() - // Calm down music - for i := 0; i < 2000; i += 1 { - volume.Volume -= 0.001 - time.Sleep(4 * time.Millisecond) - } + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + log.Println("Stopping the service...") + a.Stop() + log.Println("Stopped") } diff --git a/model/action.go b/model/action.go new file mode 100644 index 0000000..9c1ce81 --- /dev/null +++ b/model/action.go @@ -0,0 +1,132 @@ +package reveil + +import ( + "bufio" + "crypto/sha512" + "errors" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + + "git.nemunai.re/nemunaire/reveil/config" +) + +type Action struct { + Id Identifier `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path string `json:"path"` + Enabled bool `json:"enabled"` +} + +func LoadAction(path string) (string, string, error) { + fd, err := os.Open(path) + if err != nil { + return "", "", err + } + defer fd.Close() + + fileScanner := bufio.NewScanner(fd) + fileScanner.Split(bufio.ScanLines) + + var ( + shebang = false + name = "" + description = "" + ) + for fileScanner.Scan() { + line := strings.TrimSpace(fileScanner.Text()) + if !shebang { + if len(line) < 2 || line[0] != '#' || line[1] != '!' { + return name, description, errors.New("Not a valid action file (shebang not found).") + } + shebang = true + continue + } + + if len(line) < 2 || line[0] != '#' { + if len(description) > 0 { + return name, strings.TrimSpace(description), nil + } + + continue + } + + if len(name) == 0 { + name = strings.TrimSpace(line[1:]) + } else { + description += strings.TrimSpace(line[1:]) + "\n" + } + } + + return name, description, nil +} + +func LoadActions(cfg *config.Config) (actions []*Action, err error) { + actionsDir, err := filepath.Abs(cfg.ActionsDir) + if err != nil { + return nil, err + } + + err = filepath.Walk(cfg.ActionsDir, func(path string, d fs.FileInfo, err error) error { + if d.Mode().IsRegular() { + hash := sha512.Sum512([]byte(path)) + + // Parse content + name, description, err := LoadAction(path) + if err != nil { + log.Printf("Invalid action file (trying to parse %s): %s", path, err.Error()) + // Ignore invalid files + return nil + } + + if description == "" { + return nil + } + + if apath, err := filepath.Abs(path); err == nil { + path = apath + } + + actions = append(actions, &Action{ + Id: hash[:], + Name: name, + Description: description, + Path: strings.TrimPrefix(path, actionsDir+"/"), + Enabled: d.Mode().Perm()&0111 != 0, + }) + } + + return nil + }) + + return +} + +func (a *Action) Rename(name string) error { + return errors.New("Not implemeted") +} + +func (a *Action) Enable() error { + fi, err := os.Stat(a.Path) + if err != nil { + return err + } + + return os.Chmod(a.Path, fi.Mode().Perm()|0111) +} + +func (a *Action) Disable() error { + fi, err := os.Stat(a.Path) + if err != nil { + return err + } + + return os.Chmod(a.Path, fi.Mode().Perm()&0666) +} + +func (a *Action) Remove() error { + return os.Remove(a.Path) +} diff --git a/model/alarm.go b/model/alarm.go new file mode 100644 index 0000000..e6a8bbd --- /dev/null +++ b/model/alarm.go @@ -0,0 +1,321 @@ +package reveil + +import ( + "fmt" + "sort" + "time" +) + +type Date time.Time + +func (d *Date) MarshalJSON() (dst []byte, err error) { + return []byte(fmt.Sprintf("\"%04d-%02d-%02d\"", time.Time(*d).Year(), time.Time(*d).Month(), time.Time(*d).Day())), nil +} + +func (d *Date) UnmarshalJSON(src []byte) error { + tmp, err := time.Parse("\"2006-01-02\"", string(src)) + if err != nil { + return err + } + *d = Date(tmp) + + return nil +} + +type Hour time.Time + +func (h *Hour) MarshalJSON() (dst []byte, err error) { + return []byte(fmt.Sprintf("\"%02d:%02d\"", time.Time(*h).Hour(), time.Time(*h).Minute())), nil +} + +func (h *Hour) UnmarshalJSON(src []byte) error { + tmp, err := time.Parse("\"15:04\"", string(src)) + if err != nil { + return err + } + *h = Hour(tmp) + + return nil +} + +func GetNextAlarm(db *LevelDBStorage) (*time.Time, error) { + alarmsRepeated, err := GetAlarmsRepeated(db) + if err != nil { + return nil, err + } + + var closestAlarm *time.Time + for _, alarm := range alarmsRepeated { + next := alarm.GetNextOccurence(db) + if next != nil && (closestAlarm == nil || closestAlarm.After(*next)) { + closestAlarm = next + } + } + + alarmsSingle, err := GetAlarmsSingle(db) + if err != nil { + return nil, err + } + + now := time.Now() + for _, alarm := range alarmsSingle { + if closestAlarm == nil || (closestAlarm.After(alarm.Time) && alarm.Time.After(now)) { + closestAlarm = &alarm.Time + } + } + + return closestAlarm, nil +} + +type Exceptions []time.Time + +func (e Exceptions) Len() int { + return len(e) +} + +func (e Exceptions) Less(i, j int) bool { + return e[i].Before(e[j]) +} + +func (e Exceptions) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +type AlarmRepeated struct { + Id Identifier `json:"id"` + Weekday time.Weekday `json:"weekday"` + StartTime *Hour `json:"time"` + FollowingRoutines []Identifier `json:"routines"` + IgnoreExceptions bool `json:"ignore_exceptions"` + Comment string `json:"comment,omitempty"` + Excepts Exceptions `json:"excepts,omitempty"` + NextTime *time.Time `json:"next_time,omitempty"` +} + +func (a *AlarmRepeated) FillExcepts(db *LevelDBStorage) error { + if a.IgnoreExceptions { + return nil + } + + exceptions, err := GetAlarmExceptions(db) + if err != nil { + return err + } + + now := time.Now() + + for _, exception := range exceptions { + if now.After(time.Time(*exception.Start)) { + continue + } + + end := time.Time(*exception.End).AddDate(0, 0, 1) + for t := time.Time(*exception.Start); end.After(t); t = t.AddDate(0, 0, 1) { + if t.Weekday() == a.Weekday { + a.Excepts = append(a.Excepts, time.Date(t.Year(), t.Month(), t.Day(), time.Time(*a.StartTime).Hour(), time.Time(*a.StartTime).Minute(), time.Time(*a.StartTime).Second(), 0, now.Location())) + t.AddDate(0, 0, 6) + } + } + } + + sort.Sort(a.Excepts) + + return nil +} + +func (a *AlarmRepeated) GetNextOccurence(db *LevelDBStorage) *time.Time { + if len(a.Excepts) == 0 { + a.FillExcepts(db) + } + + now := time.Now() + + today := time.Date(now.Year(), now.Month(), now.Day(), time.Time(*a.StartTime).Hour(), time.Time(*a.StartTime).Minute(), time.Time(*a.StartTime).Second(), 0, now.Location()) + if now.After(today) { + today = today.AddDate(0, 0, 1) + } + + end := today.AddDate(0, 0, 7) + var nextOccurence time.Time + for nextOccurence = today; end.After(nextOccurence); nextOccurence = nextOccurence.AddDate(0, 0, 1) { + if nextOccurence.Weekday() == a.Weekday { + break + } + } + + for _, except := range a.Excepts { + if except.Equal(nextOccurence) { + nextOccurence = nextOccurence.AddDate(0, 0, 7) + } + } + + return &nextOccurence +} + +func GetAlarmRepeated(db *LevelDBStorage, id Identifier) (alarm *AlarmRepeated, err error) { + alarm = &AlarmRepeated{} + err = db.get(fmt.Sprintf("alarm-repeated-%s", id.ToString()), alarm) + return +} + +func GetAlarmsRepeated(db *LevelDBStorage) (alarms []*AlarmRepeated, err error) { + iter := db.search("alarm-repeated-") + defer iter.Release() + + for iter.Next() { + var a AlarmRepeated + + err = decodeData(iter.Value(), &a) + if err != nil { + return + } + + alarms = append(alarms, &a) + } + + return +} + +func PutAlarmRepeated(db *LevelDBStorage, alarm *AlarmRepeated) (err error) { + var key string + var id Identifier + + if alarm.Id.IsEmpty() { + key, id, err = db.findBytesKey("alarm-repeated-", IDENTIFIER_LEN) + if err != nil { + return err + } + } + + alarm.Id = id + // Don't store this, this is autocalculated + alarm.Excepts = nil + alarm.NextTime = nil + + return db.put(key, alarm) +} + +func DeleteAlarmRepeated(db *LevelDBStorage, alarm *AlarmRepeated) (err error) { + return db.delete(fmt.Sprintf("alarm-repeated-%s", alarm.Id.ToString())) +} + +type AlarmSingle struct { + Id Identifier `json:"id"` + Time time.Time `json:"time"` + FollowingRoutines []Identifier `json:"routines"` + Comment string `json:"comment,omitempty"` +} + +func GetAlarmSingle(db *LevelDBStorage, id Identifier) (alarm *AlarmSingle, err error) { + alarm = &AlarmSingle{} + err = db.get(fmt.Sprintf("alarm-single-%s", id.ToString()), alarm) + return +} + +func GetAlarmsSingle(db *LevelDBStorage) (alarms []*AlarmSingle, err error) { + iter := db.search("alarm-single-") + defer iter.Release() + + for iter.Next() { + var a AlarmSingle + + err = decodeData(iter.Value(), &a) + if err != nil { + return + } + + alarms = append(alarms, &a) + } + + return +} + +func PutAlarmSingle(db *LevelDBStorage, alarm *AlarmSingle) (err error) { + var key string + var id Identifier + + if alarm.Id.IsEmpty() { + key, id, err = db.findBytesKey("alarm-single-", IDENTIFIER_LEN) + if err != nil { + return err + } + } + + alarm.Id = id + + return db.put(key, alarm) +} + +func DeleteAlarmSingle(db *LevelDBStorage, alarm *AlarmSingle) (err error) { + return db.delete(fmt.Sprintf("alarm-single-%s", alarm.Id.ToString())) +} + +func RemoveOldAlarmsSingle(db *LevelDBStorage) error { + alarms, err := GetAlarmsSingle(db) + if err != nil { + return err + } + + now := time.Now() + for _, alarm := range alarms { + if now.After(time.Time(alarm.Time)) { + err = DeleteAlarmSingle(db, alarm) + if err != nil { + return err + } + } + } + + return nil +} + +type AlarmException struct { + Id Identifier `json:"id"` + Start *Date `json:"start"` + End *Date `json:"end"` + Comment string `json:"comment,omitempty"` +} + +func GetAlarmException(db *LevelDBStorage, id Identifier) (alarm *AlarmException, err error) { + alarm = &AlarmException{} + err = db.get(fmt.Sprintf("alarm-exception-%s", id.ToString()), alarm) + return +} + +func GetAlarmExceptions(db *LevelDBStorage) (alarms []*AlarmException, err error) { + iter := db.search("alarm-exception-") + defer iter.Release() + + for iter.Next() { + var a AlarmException + + err = decodeData(iter.Value(), &a) + if err != nil { + return + } + + alarms = append(alarms, &a) + } + + return +} + +func PutAlarmException(db *LevelDBStorage, alarm *AlarmException) (err error) { + var key string + var id Identifier + + if alarm.Id.IsEmpty() { + key, id, err = db.findBytesKey("alarm-exception-", IDENTIFIER_LEN) + if err != nil { + return err + } + } + + alarm.Id = id + + return db.put(key, alarm) +} + +func DeleteAlarmException(db *LevelDBStorage, alarm *AlarmException) (err error) { + return db.delete(fmt.Sprintf("alarm-exception-%s", alarm.Id.ToString())) +} diff --git a/model/database.go b/model/database.go new file mode 100644 index 0000000..c54daab --- /dev/null +++ b/model/database.go @@ -0,0 +1,90 @@ +package reveil + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/errors" + "github.com/syndtr/goleveldb/leveldb/iterator" + "github.com/syndtr/goleveldb/leveldb/util" +) + +type LevelDBStorage struct { + db *leveldb.DB +} + +// NewMySQLStorage establishes the connection to the database +func NewLevelDBStorage(path string) (s *LevelDBStorage, err error) { + var db *leveldb.DB + + db, err = leveldb.OpenFile(path, nil) + if err != nil { + if _, ok := err.(*errors.ErrCorrupted); ok { + log.Println("LevelDB was corrupted; attempting recovery (%s)", err.Error()) + _, err = leveldb.RecoverFile(path, nil) + if err != nil { + return + } + log.Println("LevelDB recovery succeeded!") + } else { + return + } + } + + s = &LevelDBStorage{db} + return +} + +func (s *LevelDBStorage) Close() error { + return s.db.Close() +} + +func decodeData(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +func (s *LevelDBStorage) get(key string, v interface{}) error { + data, err := s.db.Get([]byte(key), nil) + if err != nil { + return err + } + + return decodeData(data, v) +} + +func (s *LevelDBStorage) put(key string, v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + + return s.db.Put([]byte(key), data, nil) +} + +func (s *LevelDBStorage) findBytesKey(prefix string, len int) (key string, id Identifier, err error) { + id = make([]byte, len) + found := true + for found { + if _, err = rand.Read(id); err != nil { + return + } + key = fmt.Sprintf("%s%s", prefix, id.ToString()) + + found, err = s.db.Has([]byte(key), nil) + if err != nil { + return + } + } + return +} + +func (s *LevelDBStorage) delete(key string) error { + return s.db.Delete([]byte(key), nil) +} + +func (s *LevelDBStorage) search(prefix string) iterator.Iterator { + return s.db.NewIterator(util.BytesPrefix([]byte(prefix)), nil) +} diff --git a/model/gong.go b/model/gong.go new file mode 100644 index 0000000..9b7a2f2 --- /dev/null +++ b/model/gong.go @@ -0,0 +1,130 @@ +package reveil + +import ( + "crypto/sha512" + "io/fs" + "os" + "path/filepath" + "strings" + + "git.nemunai.re/nemunaire/reveil/config" +) + +const CURRENT_GONG = "_current_gong" + +type Gong struct { + Id Identifier `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Enabled bool `json:"enabled"` +} + +func CurrentGongPath(cfg *config.Config) string { + return filepath.Join(cfg.GongsDir, CURRENT_GONG) +} + +func LoadGong(cfg *config.Config, path string, d fs.FileInfo) (gong *Gong, err error) { + // Retrieve the path of the current gong + current_gong, err := os.Readlink(CurrentGongPath(cfg)) + if err == nil { + current_gong, _ = filepath.Abs(filepath.Join(cfg.GongsDir, current_gong)) + } + + hash := sha512.Sum512([]byte(path)) + pabs, _ := filepath.Abs(path) + return &Gong{ + Id: hash[:], + Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())), + Path: path, + Enabled: current_gong == pabs, + }, nil +} + +func LoadGongs(cfg *config.Config) (gongs []*Gong, err error) { + // Retrieve the path of the current gong + current_gong, err := os.Readlink(CurrentGongPath(cfg)) + if err == nil { + current_gong, _ = filepath.Abs(filepath.Join(cfg.GongsDir, current_gong)) + } + + // Retrieve the list + err = filepath.Walk(cfg.GongsDir, func(path string, d fs.FileInfo, err error) error { + if d.Mode().IsRegular() { + hash := sha512.Sum512([]byte(path)) + pabs, _ := filepath.Abs(path) + gongs = append(gongs, &Gong{ + Id: hash[:], + Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())), + Path: path, + Enabled: current_gong == pabs, + }) + } + + return nil + }) + + return +} + +func (g *Gong) Open() (*os.File, error) { + return os.Open(g.Path) +} + +func (g *Gong) Size() (int64, error) { + if st, err := os.Stat(g.Path); err != nil { + return 0, err + } else { + return st.Size(), err + } +} + +func (g *Gong) ContentType() string { + switch filepath.Ext(g.Path) { + case ".flac": + return "audio/flac" + case ".mp3": + return "audio/mpeg" + case ".ogg": + return "audio/ogg" + case ".wav": + return "audio/vnd.wav" + } + + return "application/octet-stream" +} + +func (g *Gong) Rename(newName string) error { + newPath := filepath.Join(filepath.Dir(g.Path), newName+filepath.Ext(g.Path)) + + err := os.Rename( + g.Path, + newPath, + ) + if err != nil { + return err + } + + g.Path = newPath + return nil +} + +func (g *Gong) SetDefault(cfg *config.Config) error { + linkpath := CurrentGongPath(cfg) + os.Remove(linkpath) + + pabs, err := filepath.Abs(g.Path) + if err != nil { + pabs = g.Path + } + + gdirabs, err := filepath.Abs(cfg.GongsDir) + if err != nil { + gdirabs = cfg.GongsDir + } + + return os.Symlink(strings.TrimPrefix(strings.TrimPrefix(pabs, gdirabs), "/"), linkpath) +} + +func (g *Gong) Remove() error { + return os.Remove(g.Path) +} diff --git a/model/identifier.go b/model/identifier.go new file mode 100644 index 0000000..d1f00bb --- /dev/null +++ b/model/identifier.go @@ -0,0 +1,41 @@ +package reveil + +import ( + "encoding/base64" + "errors" +) + +const IDENTIFIER_LEN = 48 + +type Identifier []byte + +func NewIdentifierFromString(src string) (id Identifier, err error) { + return base64.RawURLEncoding.DecodeString(src) +} + +func (i *Identifier) IsEmpty() bool { + return len(*i) == 0 +} + +func (i *Identifier) ToString() string { + return base64.RawURLEncoding.EncodeToString(*i) +} + +func (i Identifier) MarshalJSON() (dst []byte, err error) { + dst = make([]byte, base64.RawURLEncoding.EncodedLen(len(i))) + base64.RawURLEncoding.Encode(dst, i) + dst = append([]byte{'"'}, dst...) + dst = append(dst, '"') + return +} + +func (i *Identifier) UnmarshalJSON(src []byte) error { + if len(src) < 2 || src[0] != '"' || src[len(src)-1] != '"' { + return errors.New("Unvalid character found to encapsulate the JSON value") + } + + *i = make([]byte, base64.RawURLEncoding.DecodedLen(len(src)-2)) + _, err := base64.RawURLEncoding.Decode(*i, src[1:len(src)-1]) + + return err +} diff --git a/model/routine.go b/model/routine.go new file mode 100644 index 0000000..86b06a2 --- /dev/null +++ b/model/routine.go @@ -0,0 +1,119 @@ +package reveil + +import ( + "crypto/sha512" + "io/fs" + "io/ioutil" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "git.nemunai.re/nemunaire/reveil/config" +) + +type RoutineStep struct { + Delay uint64 `json:"delay"` + Action string `json:"action"` + Args []string `json:"args,omitempty"` +} + +type Routine struct { + Id Identifier `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Steps []RoutineStep `json:"steps"` +} + +func LoadRoutine(path string, cfg *config.Config) ([]RoutineStep, error) { + ds, err := ioutil.ReadDir(path) + if err != nil { + return nil, err + } + + actionsDir, err := filepath.Abs(cfg.ActionsDir) + if err != nil { + return nil, err + } + + var steps []RoutineStep + + for _, f := range ds { + fullpath := filepath.Join(path, f.Name()) + if f.Mode()&os.ModeSymlink == 0 { + continue + } + + dst, err := os.Readlink(fullpath) + if err != nil { + return nil, err + } + + if adst, err := filepath.Abs(filepath.Join(path, dst)); err == nil { + dst = adst + } + + args := strings.Split(f.Name(), "_") + + delay, err := strconv.ParseUint(args[0], 10, 64) + + step := RoutineStep{ + Delay: delay, + Action: strings.TrimPrefix(dst, actionsDir+"/"), + } + if len(args) > 1 { + step.Args = args[1:] + } + + steps = append(steps, step) + } + + return steps, nil +} + +func LoadRoutines(cfg *config.Config) (routines []*Routine, err error) { + err = filepath.Walk(cfg.RoutinesDir, func(path string, d fs.FileInfo, err error) error { + if d.IsDir() && path != cfg.RoutinesDir { + hash := sha512.Sum512([]byte(path)) + + // Explore directory + steps, err := LoadRoutine(path, cfg) + if err != nil { + log.Printf("Invalid routine directory (trying to walk through %s): %s", path, err.Error()) + // Ignore invalid routines + return nil + } + + routines = append(routines, &Routine{ + Id: hash[:], + Name: d.Name(), + Path: path, + Steps: steps, + }) + } + + return nil + }) + + return +} + +func (r *Routine) Rename(newName string) error { + newPath := filepath.Join(filepath.Dir(r.Path), newName) + + err := os.Rename( + r.Path, + newPath, + ) + if err != nil { + return err + } + + r.Path = newPath + return nil +} + +func (a *Routine) Remove() error { + return os.Remove(a.Path) +} diff --git a/model/settings.go b/model/settings.go new file mode 100644 index 0000000..7c1cb51 --- /dev/null +++ b/model/settings.go @@ -0,0 +1,55 @@ +package reveil + +import ( + "encoding/json" + "os" + "time" +) + +// Settings represents the settings panel. +type Settings struct { + Language string `json:"language"` + GongInterval time.Duration `json:"gong_interval"` + WeatherDelay time.Duration `json:"weather_delay"` + WeatherAction string `json:"weather_action"` + MaxRunTime time.Duration `json:"max_run_time"` +} + +// ExistsSettings checks if the settings file can by found at the given path. +func ExistsSettings(settingsPath string) bool { + _, err := os.Stat(settingsPath) + return !os.IsNotExist(err) +} + +// ReadSettings parses the file at the given location. +func ReadSettings(path string) (*Settings, error) { + var s Settings + if fd, err := os.Open(path); err != nil { + return nil, err + } else { + defer fd.Close() + jdec := json.NewDecoder(fd) + + if err := jdec.Decode(&s); err != nil { + return &s, err + } + + return &s, nil + } +} + +// SaveSettings saves settings at the given location. +func SaveSettings(path string, s interface{}) error { + if fd, err := os.Create(path); err != nil { + return err + } else { + defer fd.Close() + jenc := json.NewEncoder(fd) + + if err := jenc.Encode(s); err != nil { + return err + } + + return nil + } +} diff --git a/model/track.go b/model/track.go new file mode 100644 index 0000000..1ec27f9 --- /dev/null +++ b/model/track.go @@ -0,0 +1,133 @@ +package reveil + +import ( + "crypto/sha512" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "git.nemunai.re/nemunaire/reveil/config" +) + +type Track struct { + Id Identifier `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Enabled bool `json:"enabled"` +} + +func LoadTrack(path string, d fs.FileInfo) (track *Track, err error) { + hash := sha512.Sum512([]byte(path)) + return &Track{ + Id: hash[:], + Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())), + Path: path, + Enabled: len(strings.Split(path, "/")) == 2, + }, nil +} + +func LoadTracks(cfg *config.Config) (tracks []*Track, err error) { + err = filepath.Walk(cfg.TracksDir, func(path string, d fs.FileInfo, err error) error { + if d.Mode().IsRegular() { + hash := sha512.Sum512([]byte(path)) + tracks = append(tracks, &Track{ + Id: hash[:], + Name: strings.TrimSuffix(d.Name(), filepath.Ext(d.Name())), + Path: path, + Enabled: len(strings.Split(path, "/")) == 2, + }) + } + + return nil + }) + + return +} + +func (t *Track) Open() (*os.File, error) { + return os.Open(t.Path) +} + +func (t *Track) Size() (int64, error) { + if st, err := os.Stat(t.Path); err != nil { + return 0, err + } else { + return st.Size(), err + } +} + +func (t *Track) ContentType() string { + switch filepath.Ext(t.Path) { + case ".flac": + return "audio/flac" + case ".mp3": + return "audio/mpeg" + case ".ogg": + return "audio/ogg" + case ".wav": + return "audio/vnd.wav" + } + + return "application/octet-stream" +} + +func (t *Track) Rename(newName string) error { + newPath := filepath.Join(filepath.Dir(t.Path), newName+filepath.Ext(t.Path)) + + err := os.Rename( + t.Path, + newPath, + ) + if err != nil { + return err + } + + t.Path = newPath + + // Recalculate hash + hash := sha512.Sum512([]byte(t.Path)) + t.Id = hash[:] + return nil +} + +func (t *Track) MoveTo(path string) error { + os.Mkdir(filepath.Dir(path), 0755) + + err := os.Rename( + t.Path, + path, + ) + if err != nil { + return err + } + + t.Path = path + + // Recalculate hash + hash := sha512.Sum512([]byte(t.Path)) + t.Id = hash[:] + return nil +} + +func (t *Track) Disable() error { + if t.Enabled { + date := time.Now() + return t.MoveTo(filepath.Join(filepath.Dir(t.Path), date.Format("20060102"), filepath.Base(t.Path))) + } + + return nil +} + +func (t *Track) Enable(cfg *config.Config) error { + if !t.Enabled { + return t.MoveTo(filepath.Join(cfg.TracksDir, filepath.Base(t.Path))) + } + + return nil +} + +func (t *Track) Remove() error { + return os.Remove(t.Path) +} diff --git a/player/player.go b/player/player.go new file mode 100644 index 0000000..2162fd1 --- /dev/null +++ b/player/player.go @@ -0,0 +1,256 @@ +package player + +import ( + "fmt" + "log" + "math" + "math/rand" + "os" + "os/signal" + "path" + "strings" + "syscall" + "time" + + "github.com/faiface/beep" + "github.com/faiface/beep/effects" + "github.com/faiface/beep/flac" + "github.com/faiface/beep/mp3" + "github.com/faiface/beep/speaker" + "github.com/faiface/beep/wav" + + "git.nemunai.re/nemunaire/reveil/config" + "git.nemunai.re/nemunaire/reveil/model" +) + +var CommonPlayer *Player + +type Player struct { + Playlist []string + MaxRunTime time.Duration + Stopper chan bool + + sampleRate beep.SampleRate + + claironTime time.Duration + claironFile string + + ntick int64 + hasClaironed bool + launched time.Time + volume *effects.Volume + dontUpdateVolume bool + reverseOrder bool + playedItem int +} + +func WakeUp(cfg *config.Config) (err error) { + if CommonPlayer != nil { + return fmt.Errorf("Unable to start the player: a player is already running") + } + + CommonPlayer, err = NewPlayer(cfg) + if err != nil { + return err + } + + go CommonPlayer.WakeUp() + return nil +} + +func NewPlayer(cfg *config.Config) (*Player, error) { + // Load our settings + settings, err := reveil.ReadSettings(cfg.SettingsFile) + if err != nil { + return nil, fmt.Errorf("Unable to read settings: %w", err) + } + + p := Player{ + Stopper: make(chan bool, 1), + MaxRunTime: settings.MaxRunTime * time.Minute, + sampleRate: beep.SampleRate(cfg.SampleRate), + claironTime: settings.GongInterval * time.Minute, + claironFile: reveil.CurrentGongPath(cfg), + } + + // Load our track list + tracks, err := reveil.LoadTracks(cfg) + if err != nil { + return nil, fmt.Errorf("Unable to load tracks: %w", err) + } + + var playlist []string + + // Creating playlist + log.Println("Loading playlist...") + for _, track := range tracks { + if !track.Enabled { + continue + } + + p.Playlist = append(p.Playlist, track.Path) + } + + log.Println("Shuffling playlist...") + // Shuffle the playlist + rand.Shuffle(len(playlist), func(i, j int) { + playlist[i], playlist[j] = playlist[j], playlist[i] + }) + + return &p, nil +} + +func loadFile(filepath string) (name string, s beep.StreamSeekCloser, format beep.Format, err error) { + var fd *os.File + + name = path.Base(filepath) + + fd, err = os.Open(filepath) + if err != nil { + return + } + + switch strings.ToLower(path.Ext(filepath)) { + case ".flac": + s, format, err = flac.Decode(fd) + case ".mp3": + s, format, err = mp3.Decode(fd) + default: + s, format, err = wav.Decode(fd) + } + + if err != nil { + fd.Close() + return + } + + return +} + +func (p *Player) WakeUp() { + log.Println("RUN WAKEUP FUNC") + + log.Println("Playlist in use:", strings.Join(p.Playlist, " ; ")) + + // Create infinite stream + stream := beep.Iterate(func() beep.Streamer { + if !p.hasClaironed && time.Since(p.launched) >= p.claironTime { + log.Println("clairon time!") + p.claironTime += p.claironTime / 2 + _, sample, format, err := loadFile(p.claironFile) + if err == nil { + p.volume.Volume = 0.1 + p.dontUpdateVolume = true + if format.SampleRate != p.sampleRate { + return beep.Resample(3, format.SampleRate, p.sampleRate, sample) + } else { + return sample + } + } else { + log.Println("Error loading clairon:", err) + } + } + + p.dontUpdateVolume = false + p.volume.Volume = -2 - math.Log(5/float64(p.ntick))/3 + + if p.reverseOrder { + p.playedItem -= 1 + } else { + p.playedItem += 1 + } + + if p.playedItem >= len(p.Playlist) { + p.playedItem = 0 + } else if p.playedItem < 0 { + p.playedItem = len(p.Playlist) - 1 + } + + // Load our current item + _, sample, format, err := loadFile(p.Playlist[p.playedItem]) + if err != nil { + log.Println("Error loading audio file %s: %s", p.Playlist[p.playedItem], err.Error()) + return nil + } + + // Resample if needed + log.Println("playing list item:", p.playedItem, "/", len(p.Playlist), ":", p.Playlist[p.playedItem]) + if format.SampleRate != p.sampleRate { + return beep.Resample(3, format.SampleRate, p.sampleRate, sample) + } else { + return sample + } + }) + + // Prepare sound player + log.Println("Initializing sound player...") + speaker.Init(p.sampleRate, p.sampleRate.N(time.Second/10)) + defer speaker.Close() + + p.volume = &effects.Volume{stream, 10, -2, false} + speaker.Play(p.volume) + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + p.launched = time.Now() + + // Prepare graceful shutdown + maxRun := time.After(p.MaxRunTime) + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, syscall.SIGHUP) + +loop: + for { + select { + case <-p.Stopper: + log.Println("Stopper activated") + break loop + case <-maxRun: + log.Println("Max run time exhausted") + break loop + case <-ticker.C: + p.ntick += 1 + if !p.dontUpdateVolume { + p.volume.Volume = -2 - math.Log(5/float64(p.ntick))/3 + } + case <-interrupt: + break loop + } + } + + log.Println("Stopping the player...") + + // Calm down music +loopcalm: + for i := 0; i < 2000; i += 1 { + p.volume.Volume -= 0.001 + + timer := time.NewTimer(4 * time.Millisecond) + select { + case <-p.Stopper: + log.Println("Hard stop received...") + timer.Stop() + p.volume.Volume = 0 + break loopcalm + case <-timer.C: + break + } + } + + if p == CommonPlayer { + log.Println("Destoying common player") + + CommonPlayer = nil + + // TODO: find a better way to deallocate the card + os.Exit(42) + } +} + +func (p *Player) Stop() error { + log.Println("Trying to stop the player") + p.Stopper <- true + + return nil +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7155a72 --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "packageRules": [ + { + "matchPackageNames": ["alpine", "github.com/gin-gonic/gin"], + "automerge": true, + "automergeType": "branch" + } + ] +} diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs new file mode 100644 index 0000000..3ccf435 --- /dev/null +++ b/ui/.eslintrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], + plugins: ['svelte3', '@typescript-eslint'], + ignorePatterns: ['*.cjs'], + overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], + settings: { + 'svelte3/typescript': () => require('typescript') + }, + parserOptions: { + sourceType: 'module', + ecmaVersion: 2020 + }, + env: { + browser: true, + es2017: true, + node: true + } +}; diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..f4401a3 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 0000000..ff2677e --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,6 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..82510ca --- /dev/null +++ b/ui/README.md @@ -0,0 +1,38 @@ +# create-svelte + +Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte); + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npm init svelte@next + +# create a new project in my-app +npm init svelte@next my-app +``` + +> Note: the `@next` is temporary + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment. Then: + +```bash +npm run build +``` + +> You can preview the built app with `npm run preview`, regardless of whether you installed an adapter. This should _not_ be used to serve your app in production. diff --git a/ui/assets-dev.go b/ui/assets-dev.go new file mode 100644 index 0000000..fac4f77 --- /dev/null +++ b/ui/assets-dev.go @@ -0,0 +1,32 @@ +//go:build dev +// +build dev + +package ui + +import ( + "flag" + "net/http" + "os" + "path/filepath" +) + +var ( + Assets http.FileSystem + StaticDir string = "ui/" +) + +func init() { + flag.StringVar(&StaticDir, "static", StaticDir, "Directory containing static files") +} + +func sanitizeStaticOptions() error { + StaticDir, _ = filepath.Abs(StaticDir) + if _, err := os.Stat(StaticDir); os.IsNotExist(err) { + StaticDir, _ = filepath.Abs(filepath.Join(filepath.Dir(os.Args[0]), "ui")) + if _, err := os.Stat(StaticDir); os.IsNotExist(err) { + return err + } + } + Assets = http.Dir(StaticDir) + return nil +} diff --git a/ui/assets.go b/ui/assets.go new file mode 100644 index 0000000..6ec6008 --- /dev/null +++ b/ui/assets.go @@ -0,0 +1,28 @@ +//go:build !dev +// +build !dev + +package ui + +import ( + "embed" + "io/fs" + "log" + "net/http" +) + +//go:embed all:build +var _assets embed.FS + +var Assets http.FileSystem + +func init() { + sub, err := fs.Sub(_assets, "build") + if err != nil { + log.Fatal("Unable to cd to ui/build directory:", err) + } + Assets = http.FS(sub) +} + +func sanitizeStaticOptions() error { + return nil +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..2a9146b --- /dev/null +++ b/ui/package.json @@ -0,0 +1,42 @@ +{ + "name": "reveil", + "version": "0.0.1", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "package": "vite package", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", + "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^1.0.0-next.18", + "@sveltejs/adapter-static": "^1.0.0-next.26", + "@sveltejs/kit": "^1.0.0-next.260", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "bootstrap": "^5.1.3", + "bootstrap-icons": "^1.8.0", + "bootswatch": "^5.1.3", + "eslint": "^8.0.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-svelte3": "^4.0.0", + "prettier": "^2.4.1", + "prettier-plugin-svelte": "^2.6.0", + "svelte": "^3.46.4", + "svelte-check": "^2.4.2", + "svelte-preprocess": "^4.10.2", + "tslib": "^2.3.1", + "typescript": "^4.5.5" + }, + "type": "module", + "dependencies": { + "dayjs": "^1.11.5", + "sass": "^1.49.7", + "sass-loader": "^13.0.0", + "sveltestrap": "^5.8.3", + "vite": "^3.0.0" + } +} diff --git a/ui/routes.go b/ui/routes.go new file mode 100644 index 0000000..cd554a5 --- /dev/null +++ b/ui/routes.go @@ -0,0 +1,82 @@ +package ui + +import ( + "io" + "net/http" + "net/url" + "path" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/reveil/config" +) + +func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc { + if cfg.DevProxy != "" { + // Forward to the Vue dev proxy + return func(c *gin.Context) { + if u, err := url.Parse(cfg.DevProxy); err != nil { + http.Error(c.Writer, err.Error(), http.StatusInternalServerError) + } else { + if forced_url != "" { + u.Path = path.Join(u.Path, forced_url) + } else { + u.Path = path.Join(u.Path, c.Request.URL.Path) + } + + if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil { + http.Error(c.Writer, err.Error(), http.StatusInternalServerError) + } else if resp, err := http.DefaultClient.Do(r); err != nil { + http.Error(c.Writer, err.Error(), http.StatusBadGateway) + } else { + defer resp.Body.Close() + + for key := range resp.Header { + c.Writer.Header().Add(key, resp.Header.Get(key)) + } + c.Writer.WriteHeader(resp.StatusCode) + + io.Copy(c.Writer, resp.Body) + } + } + } + } else if forced_url != "" { + // Serve forced_url + return func(c *gin.Context) { + c.FileFromFS(forced_url, Assets) + } + } else { + // Serve requested file + return func(c *gin.Context) { + c.FileFromFS(c.Request.URL.Path, Assets) + } + } +} + +func DeclareRoutes(router *gin.Engine, cfg *config.Config) { + if cfg.DevProxy != "" { + router.GET("/.svelte-kit/*_", serveOrReverse("", cfg)) + router.GET("/node_modules/*_", serveOrReverse("", cfg)) + router.GET("/@vite/*_", serveOrReverse("", cfg)) + router.GET("/@fs/*_", serveOrReverse("", cfg)) + router.GET("/src/*_", serveOrReverse("", cfg)) + } + + router.GET("/", serveOrReverse("", cfg)) + router.GET("/alarms", serveOrReverse("/", cfg)) + router.GET("/alarms/*_", serveOrReverse("/", cfg)) + router.GET("/settings", serveOrReverse("/", cfg)) + router.GET("/settings/*_", serveOrReverse("/", cfg)) + router.GET("/routines", serveOrReverse("/", cfg)) + router.GET("/routines/*_", serveOrReverse("/", cfg)) + router.GET("/musiks", serveOrReverse("/", cfg)) + router.GET("/musiks/*_", serveOrReverse("/", cfg)) + router.GET("/history", serveOrReverse("/", cfg)) + router.GET("/history/*_", serveOrReverse("/", cfg)) + + router.GET("/_app/*_", serveOrReverse("", cfg)) + router.GET("/img/*_", serveOrReverse("", cfg)) + router.GET("/favicon.ico", serveOrReverse("", cfg)) + router.GET("/manifest.json", serveOrReverse("", cfg)) + router.GET("/service-worker.js", serveOrReverse("", cfg)) +} diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 0000000..cb871a8 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,19 @@ + + +
+ + + + + + + + + +Pas d'exception programmée
+ {/if} + {:else} + {#await alarmsExceptions.refresh()} +Pas de réveil habituel programmé
+ {/if} + {:else} + {#await alarmsRepeated.refresh()} +Pas de prochain réveil manuel programmé
+ {/if} + {:else} + {#await alarmsSingle.refresh()} ++ 10 minutes +
+++{$quotes.quoteOfTheDay.content}
+
+ Choisissez {slugToText($page.params.kind)} dans la liste. +
++ {alarm.comment} +
+ {/if} ++ {alarm.comment} +
+ {/if} ++ {exception.comment} +
+ {/if} + Entre le+ Choisissez la piste dans la liste. +
++ Êtes-vous sûr ? +
++ Choisissez la piste dans la liste. +
++ Êtes-vous sûr ? +
++ Choisissez la piste dans la liste. +
++ {action.description} +
+