Crontab installation support
This commit is contained in:
parent
e1fa2f35f2
commit
3e8b817796
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1 +1,5 @@
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
sonar.pi/
|
||||||
media/
|
media/
|
||||||
|
crontab.in/
|
||||||
|
|
20
Makefile
20
Makefile
|
@ -18,6 +18,26 @@ build:
|
||||||
gofmt -w .
|
gofmt -w .
|
||||||
go build $(LDFLAGS) -i -o bin/sonar cmd/sonar/main.go
|
go build $(LDFLAGS) -i -o bin/sonar cmd/sonar/main.go
|
||||||
|
|
||||||
|
# `make dist` makes a distribution bundle.
|
||||||
|
.PHONY: dist
|
||||||
|
dist:
|
||||||
|
rm -rf dist
|
||||||
|
mkdir dist
|
||||||
|
cp -r crontab.in www dist/
|
||||||
|
go build $(LDFLAGS) -i -o dist/sonar cmd/sonar/main.go
|
||||||
|
cd dist
|
||||||
|
tar -czvf sonar-$(VERSION).tar.gz *
|
||||||
|
mv *.tar.gz ../
|
||||||
|
|
||||||
|
# `make pi` makes a distribution for the Raspberry Pi.
|
||||||
|
.PHONY: pi
|
||||||
|
pi:
|
||||||
|
rm -rf sonar.pi
|
||||||
|
mkdir sonar.pi
|
||||||
|
cp -r crontab.in www sonar.pi/
|
||||||
|
GOOS=linux GOARCH=arm go build $(LDFLAGS) -i -o sonar.pi/sonar cmd/sonar/main.go
|
||||||
|
zip -r sonar-pi.zip sonar.pi
|
||||||
|
|
||||||
# `make run` to run it in debug mode.
|
# `make run` to run it in debug mode.
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
|
|
169
README.md
169
README.md
|
@ -1,3 +1,170 @@
|
||||||
# sonar
|
# sonar
|
||||||
|
|
||||||
Raspberry Pi alarm clock server.
|
Sonar is an alarm clock server designed to run on a Raspberry Pi, but it could
|
||||||
|
just as well work anywhere.
|
||||||
|
|
||||||
|
For the alarm clock it plays a list of media files from the filesystem. By
|
||||||
|
default it will use the `mplayer` command.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
./sonar [-listen 127.0.0.1:8000]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sonar has no authentication system. It listens on localhost by default and you
|
||||||
|
should put a proxy like nginx in front with HTTP Basic Auth or whatever.
|
||||||
|
|
||||||
|
It will create its config on first startup.
|
||||||
|
|
||||||
|
# Features
|
||||||
|
|
||||||
|
It listens on an HTTP service and shows a GUI on the homepage where you can
|
||||||
|
toggle the volume settings, start/stop the alarm clock playlist, and see/change
|
||||||
|
the scheduled alarm times.
|
||||||
|
|
||||||
|
The clock is controlled over simple RESTful API. You just post to these
|
||||||
|
endpoints:
|
||||||
|
|
||||||
|
* `/volume/higher`: increase volume by 5% (default)
|
||||||
|
* `/volume/lower`: lower volume by 5%
|
||||||
|
* `/volume/mute`: toggle mute status
|
||||||
|
* `/playlist/start`: start the playlist (doesn't stop automatically!)
|
||||||
|
* `/playlist/stop`: stop the playlist
|
||||||
|
|
||||||
|
Example to start the playlist via `curl`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ curl -X POST http://localhost:8000/playlist/start
|
||||||
|
```
|
||||||
|
|
||||||
|
# Screenshots
|
||||||
|
|
||||||
|
![screenshot1.png](screenshot1.png)![screenshot2.png](screenshot2.png)
|
||||||
|
|
||||||
|
# How It Works
|
||||||
|
|
||||||
|
The config file specifies the shell commands to run to launch your media player,
|
||||||
|
volume changing commands, etc.
|
||||||
|
|
||||||
|
When the playlist starts, the Go app shuffles the files in your media folder
|
||||||
|
and feeds them one by one to your media player command (`mplayer` by default).
|
||||||
|
To stop the playlist, it kills the current mplayer task and stops.
|
||||||
|
|
||||||
|
When you save a schedule for the alarm clock, it will create and install a
|
||||||
|
crontab entry for the user running the app. The cron entry hits the API server
|
||||||
|
to start the playlist at the desired time, and then, an hour later, it stops
|
||||||
|
it the same way.
|
||||||
|
|
||||||
|
## Crontab
|
||||||
|
|
||||||
|
The schedule system installs into the user's local crontab. The cron entries
|
||||||
|
just post back to the API service, like:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
30 5 * * * curl -X POST http://127.0.0.1:8000/playlist/start
|
||||||
|
30 6 * * * curl -X POST http://127.0.0.1:8000/playlist/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
The stop command is installed one hour after the start.
|
||||||
|
|
||||||
|
The user's local crontab is **overwritten** by the one Sonar installs. To keep
|
||||||
|
custom crontab entries, place them into the `crontab.in/` directory.
|
||||||
|
|
||||||
|
All custom user crontabs are concatenated together ahead of Sonar's cron entries.
|
||||||
|
The `000-header.cron` is the standard Debian cron header and tends to be installed
|
||||||
|
on top.
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
## Supervisor
|
||||||
|
|
||||||
|
There's an example supervisor config in the `etc/` folder.
|
||||||
|
|
||||||
|
Add it to supervisor and put nginx in front with Basic Auth.
|
||||||
|
|
||||||
|
# Makefile
|
||||||
|
|
||||||
|
* `make setup` to fetch dependencies.
|
||||||
|
* `make build` to build the binary to `bin/`
|
||||||
|
* `make dist` to build a distribution for your current setup
|
||||||
|
* `make run` to run it in debug mode
|
||||||
|
* `make watch` to run it in debug mode, auto-reloading (sometimes flaky control over mplayer tho!)
|
||||||
|
* `make pi` to build a zipped distribution for Raspberry Pi.
|
||||||
|
See [Cross Compile for Raspberry Pi](#cross-compile-for-raspberry-pi)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
The config file will be in your system's native location, which is
|
||||||
|
`~/.config/sonar.json` on Linux environments.
|
||||||
|
|
||||||
|
After running the app once, it will save its default configuration to disk.
|
||||||
|
The defaults are fine for PulseAudio setups but you may want to revise it to
|
||||||
|
be sure.
|
||||||
|
|
||||||
|
A default config file looks like this, annotated:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"cookieName": "session", // name of HTTP session cookie
|
||||||
|
"mediaPath": "./media", // path of media files (like .mp3) to shuffle and play
|
||||||
|
"mediaCommand": [
|
||||||
|
// The command to actually play the media. Use %s where the filename goes.
|
||||||
|
"mplayer",
|
||||||
|
"%s"
|
||||||
|
],
|
||||||
|
"volUpCommand": [
|
||||||
|
"pactl",
|
||||||
|
"set-sink-volume",
|
||||||
|
"0", // Sink number, from `pactl list-sinks`
|
||||||
|
"+5%" // 5% step
|
||||||
|
],
|
||||||
|
"volDnCommand": [
|
||||||
|
"pactl",
|
||||||
|
"set-sink-volume",
|
||||||
|
"0", // Sink number
|
||||||
|
"-5%"
|
||||||
|
],
|
||||||
|
"volMuteCommand": [
|
||||||
|
"pactl",
|
||||||
|
"set-sink-mute",
|
||||||
|
"0",
|
||||||
|
"toggle"
|
||||||
|
],
|
||||||
|
"volStatusCommand": [
|
||||||
|
// How to get the volume status. The command
|
||||||
|
// should output just a value like: 56%
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"pacmd dump-volumes | grep \"Sink 0\" | egrep -o '([0-9]+)%' | head -1"
|
||||||
|
],
|
||||||
|
// scheduled alarm time by default
|
||||||
|
"hour": 6,
|
||||||
|
"minute": 30,
|
||||||
|
"days": [
|
||||||
|
"1", "2", "3", "4", "5"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross Compile for Raspberry Pi
|
||||||
|
|
||||||
|
Use the `make pi` command to build a distribution for Raspberry Pi.
|
||||||
|
|
||||||
|
If you get permission errors when trying to download the standard library for
|
||||||
|
ARM64, make and chown the folders as a workaround:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir /usr/lib/golang/pkg/linux_arm
|
||||||
|
sudo chown kirsle:kirsle /usr/lib/golang/pkg/linux_arm
|
||||||
|
make pi
|
||||||
|
rsync -av sonar.pi 192.168.0.102:
|
||||||
|
```
|
||||||
|
|
||||||
|
It outputs a `sonar-pi.zip` that you can scp over and run.
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
Noah Petherbridge © 2018
|
||||||
|
|
||||||
|
GPLv2.
|
||||||
|
|
|
@ -2,25 +2,38 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kirsle.net/apps/sonar"
|
"git.kirsle.net/apps/sonar"
|
||||||
"github.com/kirsle/golog"
|
"github.com/kirsle/golog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build hash.
|
||||||
|
var Build = "N/A"
|
||||||
|
|
||||||
var debug bool
|
var debug bool
|
||||||
var listen string
|
var listen string
|
||||||
|
var version bool
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rand.Seed(time.Now().UnixNano())
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
|
||||||
flag.StringVar(&listen, "listen", "127.0.0.1:8000", "Interface to listen on, default localhost only")
|
flag.StringVar(&listen, "listen", "127.0.0.1:8000", "Interface to listen on, default localhost only")
|
||||||
flag.BoolVar(&debug, "debug", false, "Debug level logging")
|
flag.BoolVar(&debug, "debug", false, "Debug level logging")
|
||||||
|
flag.BoolVar(&version, "version", false, "Version number")
|
||||||
|
flag.BoolVar(&version, "v", false, "Version number (alias)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if version {
|
||||||
|
fmt.Printf("sonar v%s build %s\n", sonar.Version, Build)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
log := golog.GetLogger("sonar")
|
log := golog.GetLogger("sonar")
|
||||||
log.Config.Level = golog.DebugLevel
|
log.Config.Level = golog.DebugLevel
|
||||||
|
|
11
config.go
11
config.go
|
@ -23,6 +23,11 @@ type Config struct {
|
||||||
VolumeDownCommand []string `json:"volDnCommand"`
|
VolumeDownCommand []string `json:"volDnCommand"`
|
||||||
VolumeMuteCommand []string `json:"volMuteCommand"`
|
VolumeMuteCommand []string `json:"volMuteCommand"`
|
||||||
VolumeStatusCommand []string `json:"volStatusCommand"`
|
VolumeStatusCommand []string `json:"volStatusCommand"`
|
||||||
|
|
||||||
|
// Scheduled cron tab.
|
||||||
|
Hour int `json:"hour"`
|
||||||
|
Minute int `json:"minute"`
|
||||||
|
Days []string `json:"days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns the default config.
|
// DefaultConfig returns the default config.
|
||||||
|
@ -32,6 +37,11 @@ func DefaultConfig() *Config {
|
||||||
MediaPath: "./media",
|
MediaPath: "./media",
|
||||||
MediaCommand: []string{"mplayer", "%s"},
|
MediaCommand: []string{"mplayer", "%s"},
|
||||||
|
|
||||||
|
// Default schedule.
|
||||||
|
Hour: 6,
|
||||||
|
Minute: 30,
|
||||||
|
Days: []string{"1", "2", "3", "4", "5"}, // M-F
|
||||||
|
|
||||||
// PulseAudio pactl commands.
|
// PulseAudio pactl commands.
|
||||||
// pactl set-sink-volume <SINK> -<INT>%
|
// pactl set-sink-volume <SINK> -<INT>%
|
||||||
// Find the sink number from: pactl list
|
// Find the sink number from: pactl list
|
||||||
|
@ -65,6 +75,7 @@ func LoadConfig() *Config {
|
||||||
}
|
}
|
||||||
defer fh.Close()
|
defer fh.Close()
|
||||||
|
|
||||||
|
log.Info("Reading config from %s", filepath)
|
||||||
var c *Config
|
var c *Config
|
||||||
decoder := json.NewDecoder(fh)
|
decoder := json.NewDecoder(fh)
|
||||||
decoder.Decode(&c)
|
decoder.Decode(&c)
|
||||||
|
|
78
cron.go
Normal file
78
cron.go
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSchedule installs the crontab.
|
||||||
|
func (s *Sonar) SetSchedule(tod time.Time, days []string) error {
|
||||||
|
// Assemble our new crontab entries.
|
||||||
|
entries := []string{
|
||||||
|
// m h dom mon dow command
|
||||||
|
fmt.Sprintf("%d %d * * %s %s",
|
||||||
|
tod.Minute(),
|
||||||
|
tod.Hour(),
|
||||||
|
strings.Join(days, ","),
|
||||||
|
"curl -X POST http://"+s.url+"/playlist/start",
|
||||||
|
),
|
||||||
|
fmt.Sprintf("%d %d * * %s %s",
|
||||||
|
tod.Minute(),
|
||||||
|
tod.Add(1*time.Hour).Hour(),
|
||||||
|
strings.Join(days, ","),
|
||||||
|
"curl -X POST http://"+s.url+"/playlist/stop",
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concat all the user's custom crontab entries.
|
||||||
|
crons, err := BuildCrontab()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SetSchedule: BuildCrontab: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final cron file is the user's custom crons plus the new ones.
|
||||||
|
crontab := strings.Join(append(crons, entries...), "\n")
|
||||||
|
fmt.Println(crontab)
|
||||||
|
|
||||||
|
// Install the crontab.
|
||||||
|
cmd := exec.Command("crontab", "-")
|
||||||
|
var out bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdin = strings.NewReader(crontab)
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
err = cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SetSchedule: crontab -: %s\nSTDOUT: %s\nSTDERR: %s", err, out.String(), stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCrontab concatenates all the crontabs from crontab.in/
|
||||||
|
func BuildCrontab() ([]string, error) {
|
||||||
|
var crontab []string
|
||||||
|
err := filepath.Walk("./crontab.in", func(path string, info os.FileInfo, err error) error {
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ReadAll(%s): %s", path, err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
crontab = append(crontab, lines...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return crontab, nil
|
||||||
|
}
|
22
crontab.in/000-header.cron
Normal file
22
crontab.in/000-header.cron
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# Edit this file to introduce tasks to be run by cron.
|
||||||
|
#
|
||||||
|
# Each task to run has to be defined through a single line
|
||||||
|
# indicating with different fields when the task will be run
|
||||||
|
# and what command to run for the task
|
||||||
|
#
|
||||||
|
# To define the time you can provide concrete values for
|
||||||
|
# minute (m), hour (h), day of month (dom), month (mon),
|
||||||
|
# and day of week (dow) or use '*' in these fields (for 'any').#
|
||||||
|
# Notice that tasks will be started based on the cron's system
|
||||||
|
# daemon's notion of time and timezones.
|
||||||
|
#
|
||||||
|
# Output of the crontab jobs (including errors) is sent through
|
||||||
|
# email to the user the crontab file belongs to (unless redirected).
|
||||||
|
#
|
||||||
|
# For example, you can run a backup of all your user accounts
|
||||||
|
# at 5 a.m every week with:
|
||||||
|
# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
|
||||||
|
#
|
||||||
|
# For more information see the manual pages of crontab(5) and cron(8)
|
||||||
|
#
|
||||||
|
# m h dom mon dow command
|
6
etc/supervisor.conf
Normal file
6
etc/supervisor.conf
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[program:sonar]
|
||||||
|
command = /home/kirsle/sonar.pi/sonar
|
||||||
|
directory = /home/kirsle/sonar.pi
|
||||||
|
user = kirsle
|
||||||
|
|
||||||
|
# vim:ft=dosini
|
47
music.go
47
music.go
|
@ -3,10 +3,12 @@ package sonar
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Player manages playing music one track at a time.
|
// Player manages playing music one track at a time.
|
||||||
|
@ -16,6 +18,26 @@ type Player struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch the running playlist to advance the next track.
|
||||||
|
func (s *Sonar) Watch() {
|
||||||
|
var p = s.Player
|
||||||
|
for {
|
||||||
|
if p.Running() {
|
||||||
|
if err := p.cmd.Wait(); err != nil {
|
||||||
|
log.Error("Player.Watch: p.cmd.Wait: %s", err)
|
||||||
|
}
|
||||||
|
p.cmd = nil
|
||||||
|
|
||||||
|
// Next track?
|
||||||
|
log.Info("Track ended, play next")
|
||||||
|
if err := s.PlayNext(); err != nil {
|
||||||
|
log.Info("EOF: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run the playlist.
|
// Run the playlist.
|
||||||
func (p *Player) Run(playlist []string) {
|
func (p *Player) Run(playlist []string) {
|
||||||
p.playlist = playlist
|
p.playlist = playlist
|
||||||
|
@ -34,15 +56,17 @@ func (p *Player) Stop() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
p.cmd = nil
|
p.cmd = nil
|
||||||
|
p.playlist = []string{}
|
||||||
p.index = 0
|
p.index = 0
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next returns the next song.
|
// Next returns the next song.
|
||||||
func (p *Player) Next() (string, bool) {
|
func (p *Player) Next() (string, bool) {
|
||||||
p.index++
|
|
||||||
if p.index < len(p.playlist) {
|
if p.index < len(p.playlist) {
|
||||||
return p.playlist[p.index], true
|
next := p.playlist[p.index]
|
||||||
|
p.index++
|
||||||
|
return next, true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
@ -56,23 +80,18 @@ func (p *Player) Status() string {
|
||||||
var (
|
var (
|
||||||
index = p.index
|
index = p.index
|
||||||
length = len(p.playlist)
|
length = len(p.playlist)
|
||||||
pct float64
|
|
||||||
)
|
)
|
||||||
if length > 0 {
|
|
||||||
pct = float64(index / length)
|
|
||||||
}
|
|
||||||
parts = append(parts, fmt.Sprintf(
|
parts = append(parts, fmt.Sprintf(
|
||||||
"Track %d of %d (%.2f%%)",
|
"Track %d of %d",
|
||||||
index,
|
index,
|
||||||
length,
|
length,
|
||||||
pct,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
// Append current track.
|
// Append current track.
|
||||||
if index < len(p.playlist) {
|
if index <= len(p.playlist) {
|
||||||
parts = append(parts, fmt.Sprintf(
|
parts = append(parts, fmt.Sprintf(
|
||||||
"Now playing: %s",
|
"Now playing: %s",
|
||||||
p.playlist[index],
|
p.playlist[index-1],
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -136,9 +155,10 @@ func (s *Sonar) StartPlaylist() error {
|
||||||
|
|
||||||
// Shuffle the files.
|
// Shuffle the files.
|
||||||
log.Debug("StartPlaylist: shuffled %d media files", len(files))
|
log.Debug("StartPlaylist: shuffled %d media files", len(files))
|
||||||
// rand.Shuffle(len(files), func(i, j int) {
|
for i := range files {
|
||||||
// files[i], files[j] = files[j], files[i]
|
j := rand.Intn(i + 1)
|
||||||
// })
|
files[i], files[j] = files[j], files[i]
|
||||||
|
}
|
||||||
|
|
||||||
// Install the playlist and go.
|
// Install the playlist and go.
|
||||||
s.Player.Run(files)
|
s.Player.Run(files)
|
||||||
|
@ -176,5 +196,6 @@ func (s *Sonar) PlayNext() error {
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("end of playlist")
|
return errors.New("end of playlist")
|
||||||
}
|
}
|
||||||
|
s.Player.Stop()
|
||||||
return s.Play(filename)
|
return s.Play(filename)
|
||||||
}
|
}
|
||||||
|
|
45
routes.go
45
routes.go
|
@ -3,6 +3,7 @@ package sonar
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
)
|
)
|
||||||
|
@ -23,6 +24,11 @@ func (s *Sonar) Register() *mux.Router {
|
||||||
"Flashes": flashes,
|
"Flashes": flashes,
|
||||||
"Volume": s.Volume(),
|
"Volume": s.Volume(),
|
||||||
"PlaylistStatus": s.Player.Status(),
|
"PlaylistStatus": s.Player.Status(),
|
||||||
|
|
||||||
|
// Current schedule
|
||||||
|
"Hour": s.Config.Hour,
|
||||||
|
"Minute": s.Config.Minute,
|
||||||
|
"Days": NewSet(s.Config.Days),
|
||||||
}
|
}
|
||||||
templ.ExecuteTemplate(w, "index.gohtml", v)
|
templ.ExecuteTemplate(w, "index.gohtml", v)
|
||||||
})
|
})
|
||||||
|
@ -46,6 +52,45 @@ func (s *Sonar) Register() *mux.Router {
|
||||||
s.FlashAndRedirect(r, w, "/", "Playlist stopped!")
|
s.FlashAndRedirect(r, w, "/", "Playlist stopped!")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
POST.HandleFunc("/playlist/next", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := s.PlayNext()
|
||||||
|
if err != nil {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Error: "+err.Error())
|
||||||
|
} else {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Playlist advanced!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
POST.HandleFunc("/playlist/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
timestamp = r.FormValue("time")
|
||||||
|
days = r.Form["day"]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate the inputs.
|
||||||
|
dt, err := time.Parse("15:04", timestamp)
|
||||||
|
if err != nil {
|
||||||
|
s.Abort(r, w, "Invalid time stamp.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(days) == 0 {
|
||||||
|
s.Abort(r, w, "Select one or more days!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the intended schedule to our config on disk.
|
||||||
|
s.Config.Hour = dt.Hour()
|
||||||
|
s.Config.Minute = dt.Minute()
|
||||||
|
s.Config.Days = days
|
||||||
|
SaveConfig(s.Config)
|
||||||
|
|
||||||
|
// Save the crontab to disk.
|
||||||
|
err = s.SetSchedule(dt, days)
|
||||||
|
if err != nil {
|
||||||
|
s.Abort(r, w, err.Error())
|
||||||
|
} else {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Schedule saved!")
|
||||||
|
}
|
||||||
|
})
|
||||||
POST.HandleFunc("/volume/higher", func(w http.ResponseWriter, r *http.Request) {
|
POST.HandleFunc("/volume/higher", func(w http.ResponseWriter, r *http.Request) {
|
||||||
err := s.VolumeUp()
|
err := s.VolumeUp()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
BIN
screenshot1.png
Normal file
BIN
screenshot1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
screenshot2.png
Normal file
BIN
screenshot2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
16
sessions.go
16
sessions.go
|
@ -1,10 +1,20 @@
|
||||||
package sonar
|
package sonar
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Abort with an error.
|
||||||
|
func (s *Sonar) Abort(r *http.Request, w http.ResponseWriter, message string, v ...interface{}) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprintf(w, message, v...)
|
||||||
|
}
|
||||||
|
|
||||||
// FlashAndRedirect to flash and redirect away.
|
// FlashAndRedirect to flash and redirect away.
|
||||||
func (s *Sonar) FlashAndRedirect(r *http.Request, w http.ResponseWriter, url string, flash string, v ...string) {
|
func (s *Sonar) FlashAndRedirect(r *http.Request, w http.ResponseWriter, url string, flash string, v ...interface{}) {
|
||||||
s.Flash(r, w, flash, v...)
|
log.Debug("Flash: %s", fmt.Sprintf(flash, v...))
|
||||||
|
s.Flash(r, w, fmt.Sprintf(flash, v...))
|
||||||
w.Header().Add("Location", url)
|
w.Header().Add("Location", url)
|
||||||
w.WriteHeader(http.StatusFound)
|
w.WriteHeader(http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
13
sonar.go
13
sonar.go
|
@ -19,6 +19,7 @@ type Sonar struct {
|
||||||
Debug bool
|
Debug bool
|
||||||
Player *Player
|
Player *Player
|
||||||
Config *Config
|
Config *Config
|
||||||
|
url string
|
||||||
|
|
||||||
n *negroni.Negroni
|
n *negroni.Negroni
|
||||||
router *mux.Router
|
router *mux.Router
|
||||||
|
@ -45,16 +46,18 @@ func New() *Sonar {
|
||||||
)
|
)
|
||||||
s.n.UseHandler(router)
|
s.n.UseHandler(router)
|
||||||
|
|
||||||
|
// Watch the player to advance to the next track.
|
||||||
|
go s.Watch()
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenAndServe starts the HTTP app.
|
// ListenAndServe starts the HTTP app.
|
||||||
func (s *Sonar) ListenAndServe(address string) error {
|
func (s *Sonar) ListenAndServe(address string) error {
|
||||||
label := address
|
s.url = address
|
||||||
if strings.HasPrefix(label, ":") {
|
if strings.HasPrefix(s.url, ":") {
|
||||||
label = "0.0.0.0" + label
|
s.url = "0.0.0.0" + s.url
|
||||||
}
|
}
|
||||||
log.Info("Listening at http://%s", label)
|
log.Info("Listening at http://%s", s.url)
|
||||||
|
|
||||||
return http.ListenAndServe(address, s.n)
|
return http.ListenAndServe(address, s.n)
|
||||||
}
|
}
|
||||||
|
|
43
utils.go
Normal file
43
utils.go
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
// Set is a lookup set of strings.
|
||||||
|
type Set map[string]interface{}
|
||||||
|
|
||||||
|
// NewSet makes a new Set from a list of strings.
|
||||||
|
func NewSet(input []string) Set {
|
||||||
|
set := Set{}
|
||||||
|
for _, v := range input {
|
||||||
|
set[v] = nil
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// In queries if the value is in the set.
|
||||||
|
func (s Set) In(v string) bool {
|
||||||
|
_, ok := s[v]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SliceToSet makes a slice into a set map.
|
||||||
|
func SliceToSet(slice []string) map[string]bool {
|
||||||
|
var result = make(map[string]bool)
|
||||||
|
for _, v := range slice {
|
||||||
|
result[v] = true
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Str2IntSlice converts a string slice to an int slice.
|
||||||
|
func Str2IntSlice(str []string) ([]int, error) {
|
||||||
|
result := make([]int, len(str))
|
||||||
|
for i, literal := range str {
|
||||||
|
val, err := strconv.Atoi(literal)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
result[i] = val
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>time.caskir.net</title>
|
<title>Sonar</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta http-equiv="content-type" value="text/html; encoding=UTF-8">
|
<meta http-equiv="content-type" value="text/html; encoding=UTF-8">
|
||||||
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||||
|
@ -19,8 +19,6 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h1>time.caskir.net</h1>
|
|
||||||
|
|
||||||
{{ range .Flashes }}
|
{{ range .Flashes }}
|
||||||
<div class="alert alert-info">{{ . }}</div>
|
<div class="alert alert-info">{{ . }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -60,7 +58,7 @@
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
Alan Watts Alarm Clock
|
Alarm Clock Playlist
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>
|
<p>
|
||||||
|
@ -90,52 +88,52 @@
|
||||||
Schedule
|
Schedule
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>
|
<form method="POST" action="/playlist/schedule">
|
||||||
<a href="/crontab.txt">See the current schedule</a>
|
|
||||||
</p>
|
|
||||||
<label for="time">Time:</label>
|
<label for="time">Time:</label>
|
||||||
<input type="time" name="time" class="form-control mb-2" value="05:30">
|
<input type="time" name="time" class="form-control mb-2" value="{{ printf "%02d" .Hour }}:{{ .Minute }}">
|
||||||
|
|
||||||
<label>Days of week:</label>
|
<label>Days of week:</label>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="day" value="0"> Sunday
|
<input type="checkbox" name="day" value="0"{{ if .Days.In "0" }} checked{{ end }}> Sunday
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="day" value="1" checked> Monday
|
<input type="checkbox" name="day" value="1"{{ if .Days.In "1" }} checked{{ end }}> Monday
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="day" value="2" checked> Tuesday
|
<input type="checkbox" name="day" value="2"{{ if .Days.In "2" }} checked{{ end }}> Tuesday
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="day" value="3" checked> Wednesday
|
<input type="checkbox" name="day" value="3"{{ if .Days.In "3" }} checked{{ end }}> Wednesday
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="day" value="4" checked> Thursday
|
<input type="checkbox" name="day" value="4"{{ if .Days.In "4" }} checked{{ end }}> Thursday
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="day" value="5" checked> Friday
|
<input type="checkbox" name="day" value="5"{{ if .Days.In "5" }} checked{{ end }}> Friday
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="day" value="6"> Saturday
|
<input type="checkbox" name="day" value="6"{{ if .Days.In "6" }} checked{{ end }}> Saturday
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button name="action" value="schedule" class="form-control btn btn-primary">Set Schedule</button>
|
<button name="action" value="schedule" class="form-control btn btn-primary">Set Schedule</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user