├── appendix
├── screenshot.jpg
└── example.config.yaml
├── internal
├── vv
│ ├── assets
│ │ ├── app.png
│ │ ├── w.png
│ │ ├── app-black.png
│ │ ├── app.svg
│ │ ├── manifest.json
│ │ ├── app-black.svg
│ │ ├── nocover.svg
│ │ ├── embed.go
│ │ ├── handler.go
│ │ └── handler_test.go
│ ├── testdata
│ │ └── index.html
│ ├── api
│ │ ├── images
│ │ │ ├── testdata
│ │ │ │ ├── app.jpg
│ │ │ │ ├── app.png
│ │ │ │ ├── app.webp
│ │ │ │ ├── app-black.png
│ │ │ │ ├── Makefile
│ │ │ │ ├── app.svg
│ │ │ │ └── app-black.svg
│ │ │ ├── image_test.go
│ │ │ ├── local_test.go
│ │ │ ├── local.go
│ │ │ ├── image.go
│ │ │ ├── remote.go
│ │ │ ├── embed.go
│ │ │ └── cache.go
│ │ ├── log.go
│ │ ├── currentsong.go
│ │ ├── helpers_test.go
│ │ ├── version.go
│ │ ├── library.go
│ │ ├── neighbors.go
│ │ ├── outputs_stream.go
│ │ ├── library_songs.go
│ │ ├── playlist_songs.go
│ │ ├── outputs_stream_test.go
│ │ ├── version_test.go
│ │ ├── stats.go
│ │ ├── images.go
│ │ ├── cache_test.go
│ │ ├── stats_test.go
│ │ ├── playlist_songs_test.go
│ │ ├── storage.go
│ │ ├── cache.go
│ │ ├── neighbors_test.go
│ │ ├── batch.go
│ │ ├── currentsong_test.go
│ │ ├── outputs.go
│ │ ├── library_test.go
│ │ ├── library_songs_test.go
│ │ ├── playlist.go
│ │ └── playlist_test.go
│ ├── embed.go
│ ├── tree.go
│ ├── handler.go
│ └── lang.go
├── songs
│ ├── copy.go
│ ├── tags.go
│ ├── tags_test.go
│ ├── sort_test.go
│ └── sort.go
├── gzip
│ └── gzip.go
├── mpd
│ ├── error_test.go
│ ├── config_test.go
│ ├── mpd.conf
│ ├── conn.go
│ ├── request.go
│ ├── watcher_test.go
│ ├── error.go
│ ├── commandlist.go
│ ├── mpdtest
│ │ └── server.go
│ ├── pool.go
│ ├── watcher.go
│ ├── config.go
│ └── commandlist_test.go
├── request
│ └── request.go
└── log
│ ├── testing.go
│ └── log.go
├── package.json
├── go.mod
├── .eslintrc.json
├── Makefile
├── LICENSE
├── .github
└── workflows
│ └── test.yaml
├── README.rst
├── go.sum
├── .gitignore
├── main.go
└── config.go
/appendix/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/appendix/screenshot.jpg
--------------------------------------------------------------------------------
/internal/vv/assets/app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/internal/vv/assets/app.png
--------------------------------------------------------------------------------
/internal/vv/assets/w.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/internal/vv/assets/w.png
--------------------------------------------------------------------------------
/internal/vv/assets/app-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/internal/vv/assets/app-black.png
--------------------------------------------------------------------------------
/internal/vv/testdata/index.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/internal/vv/api/images/testdata/app.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/internal/vv/api/images/testdata/app.jpg
--------------------------------------------------------------------------------
/internal/vv/api/images/testdata/app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/internal/vv/api/images/testdata/app.png
--------------------------------------------------------------------------------
/internal/vv/api/images/testdata/app.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/internal/vv/api/images/testdata/app.webp
--------------------------------------------------------------------------------
/internal/vv/api/images/testdata/app-black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meiraka/vv/HEAD/internal/vv/api/images/testdata/app-black.png
--------------------------------------------------------------------------------
/internal/vv/embed.go:
--------------------------------------------------------------------------------
1 | package vv
2 |
3 | import (
4 | _ "embed"
5 | )
6 |
7 | var (
8 | //go:embed index.html
9 | indexHTML []byte
10 | )
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "lint": "eslint internal/vv/assets/app.js"
4 | },
5 | "devDependencies": {
6 | "eslint": "^8.30.0"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/internal/vv/api/log.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | type Logger interface {
4 | Printf(string, ...interface{})
5 | Println(...interface{})
6 | Debugf(string, ...interface{})
7 | Debugln(...interface{})
8 | }
9 |
--------------------------------------------------------------------------------
/internal/songs/copy.go:
--------------------------------------------------------------------------------
1 | package songs
2 |
3 | // Copy shallow copies songs.
4 | func Copy(s []map[string][]string) []map[string][]string {
5 | n := make([]map[string][]string, len(s))
6 | copy(n, s)
7 | return n
8 | }
9 |
--------------------------------------------------------------------------------
/internal/vv/api/images/testdata/Makefile:
--------------------------------------------------------------------------------
1 | DST=app.jpg app.png app.webp app-black.png
2 |
3 | convert: $(DST)
4 |
5 | app.jpg app.png app.webp: app.svg
6 | convert $< $@
7 |
8 | app-black.png : app-black.svg
9 | convert $< $@
10 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/meiraka/vv
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/gorilla/websocket v1.4.1
7 | github.com/spf13/pflag v1.0.3
8 | go.etcd.io/bbolt v1.3.5
9 | golang.org/x/image v0.18.0
10 | golang.org/x/text v0.16.0
11 | gopkg.in/yaml.v2 v2.2.8
12 | )
13 |
14 | require golang.org/x/sys v0.5.0 // indirect
15 |
--------------------------------------------------------------------------------
/internal/vv/assets/app.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["eslint:recommended"],
3 | "plugins": [],
4 | "parserOptions": {},
5 | "globals": {},
6 | "rules": {},
7 | "parserOptions": {
8 | "ecmaVersion": "latest"
9 | },
10 | "globals": {
11 | "TREE": false,
12 | "TREE_ORDER": false
13 | },
14 | "env": {
15 | "es6": true,
16 | "browser": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/internal/vv/api/images/testdata/app.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/internal/gzip/gzip.go:
--------------------------------------------------------------------------------
1 | package gzip
2 |
3 | import (
4 | "bytes"
5 | "compress/gzip"
6 | )
7 |
8 | // Encode encodes bytes to gzipped data.
9 | func Encode(data []byte) ([]byte, error) {
10 | var gz bytes.Buffer
11 | zw := gzip.NewWriter(&gz)
12 | _, err := zw.Write(data)
13 | if err != nil {
14 | return nil, err
15 | }
16 | if err := zw.Close(); err != nil {
17 | return nil, err
18 | }
19 | return gz.Bytes(), nil
20 | }
21 |
--------------------------------------------------------------------------------
/internal/vv/assets/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Music",
3 | "name": "Web App client for Music Player Daemon",
4 | "icons": [
5 | {
6 | "src": "/assets/app.svg",
7 | "sizes": "1024x1024",
8 | "type": "image/svg+xml"
9 | }
10 | ],
11 | "start_url": "/",
12 | "theme_color": "#ffffff",
13 | "background_color": "#ffffff",
14 | "display": "standalone"
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/internal/mpd/error_test.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "errors"
5 | "testing"
6 | )
7 |
8 | func TestAckError(t *testing.T) {
9 | for _, tt := range []struct {
10 | cmdErr error
11 | ackErr error
12 | }{
13 | {cmdErr: &CommandError{ID: 1}, ackErr: ErrNotList},
14 | {cmdErr: &CommandError{ID: 50}, ackErr: ErrNoExist},
15 | } {
16 | if !errors.Is(tt.cmdErr, tt.ackErr) {
17 | t.Errorf("%+v != %+v", tt.cmdErr, tt.ackErr)
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/internal/vv/assets/app-black.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/internal/vv/api/images/testdata/app-black.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/internal/mpd/config_test.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestParseConfig(t *testing.T) {
9 | c, err := ParseConfig("mpd.conf")
10 | if err != nil {
11 | t.Fatalf("got parse err %v", err)
12 | }
13 | want := &Config{
14 | MusicDirectory: "/mnt/Music/NAS/storage",
15 | AudioOutputs: []*ConfigAudioOutput{
16 | {Name: "My ALSA Device", Type: "alsa"},
17 | {Name: "My HTTP Stream", Type: "httpd", Port: "8000"},
18 | },
19 | }
20 | if !reflect.DeepEqual(c, want) {
21 | t.Errorf("got %+v, want %+v", c, want)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/internal/request/request.go:
--------------------------------------------------------------------------------
1 | package request
2 |
3 | import (
4 | "net/http"
5 | "time"
6 | )
7 |
8 | /*ModifiedSince compares request If-Modified-Since header and l.*/
9 | func ModifiedSince(r *http.Request, l time.Time) bool {
10 | t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since"))
11 | if err != nil {
12 | return true
13 | }
14 | return !l.Before(t.Add(time.Second))
15 | }
16 |
17 | // NoneMatch compares request If-None-Match header and etag
18 | func NoneMatch(r *http.Request, etag string) bool {
19 | return r.Header.Get("If-None-Match") == etag
20 | }
21 |
--------------------------------------------------------------------------------
/internal/vv/api/images/image_test.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "testing"
7 | )
8 |
9 | func TestResizeImage(t *testing.T) {
10 | for _, filename := range []string{
11 | "app.jpg",
12 | "app.png",
13 | "app.webp",
14 | } {
15 | t.Run(filename, func(t *testing.T) {
16 | f, err := os.Open(filepath.Join("testdata", filename))
17 | if err != nil {
18 | t.Fatalf("testfile: %s: %v", filename, err)
19 | }
20 | defer f.Close()
21 | if _, err := resizeImage(f, 8, 8); err != nil {
22 | t.Errorf("got error %v; want ", err)
23 | }
24 | })
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/internal/mpd/mpd.conf:
--------------------------------------------------------------------------------
1 | music_directory "/mnt/Music/NAS/storage"
2 |
3 | audio_output {
4 | type "alsa"
5 | name "My ALSA Device"
6 | device "hw:1,0" # optional
7 | mixer_type "software" # optional
8 | # mixer_device "default" # optional
9 | # mixer_control "PCM" # optional
10 | # mixer_index "0" # optional
11 | }
12 | audio_output {
13 | type "httpd"
14 | name "My HTTP Stream"
15 | encoder "vorbis" # optional, vorbis or lame
16 | port "8000"
17 | bind_to_address "0.0.0.0" # optional, IPv4 or IPv6
18 | # quality "5.0" # do not define if bitrate is defined
19 | # bitrate "128" # do not define if quality is defined
20 | # format "44100:16:1"
21 | max_clients "0" # optional 0=no limit
22 | }
23 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | VERSION=$(shell git describe)
2 | BUILDDIR = build
3 | TARGETS = linux-amd64 linux-armv6 linux-armv7 linux-arm64 darwin-amd64
4 | APP = vv
5 | BINARIES = $(patsubst %, $(BUILDDIR)/%/$(APP), $(TARGETS))
6 | ARCHIVES = $(patsubst %, $(BUILDDIR)/$(APP)-%.tar.gz, $(TARGETS))
7 | CHECKSUM = sha256
8 |
9 | .PHONY: build $(BINARIES) $(ARCHIVES)
10 |
11 | build:
12 | go build -ldflags "-X main.version=$(VERSION)"
13 |
14 | all: $(BINARIES)
15 | archives: $(ARCHIVES) $(BUILDDIR)/$(CHECKSUM)
16 |
17 | $(BINARIES):
18 | mkdir -p build/$(word 2,$(subst /, ,$@))
19 | GOOS=$(subst -, GOARCH=,$(subst armv,arm GOARM=,$(word 2,$(subst /, ,$@)))) go build -ldflags "-X main.version=$(VERSION)" -o $@
20 |
21 | $(ARCHIVES): $(BINARIES)
22 | tar -czf $@ -C $(subst $(APP)-,,$(word 1,$(subst ., ,$@))) $(APP)
23 |
24 | $(BUILDDIR)/$(CHECKSUM): $(BINARIES)
25 | rm -f $(BUILDDIR)/$(CHECKSUM)
26 | @LIST="$(BINARIES)";\
27 | for x in $$LIST; do\
28 | openssl $(CHECKSUM) $$x >> $(BUILDDIR)/$(CHECKSUM);\
29 | done
30 |
31 | clean:
32 | rm -rf $(BUILDDIR)
33 |
--------------------------------------------------------------------------------
/internal/mpd/conn.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "net"
7 | "strings"
8 | "time"
9 | )
10 |
11 | type conn struct {
12 | *bufio.Reader
13 | conn net.Conn
14 | Version string
15 | }
16 |
17 | func newConn(ctx context.Context, proto, addr string) (*conn, error) {
18 | dialer := net.Dialer{}
19 | c, err := dialer.DialContext(ctx, proto, addr)
20 | if err != nil {
21 | return nil, err
22 | }
23 | conn := &conn{
24 | Reader: bufio.NewReader(c),
25 | conn: c,
26 | }
27 | if deadline, ok := ctx.Deadline(); ok {
28 | conn.SetDeadline(deadline)
29 | }
30 | v, err := readln(conn)
31 | if err != nil {
32 | conn.Close()
33 | return nil, err
34 | }
35 | conn.Version = strings.TrimPrefix(v, "OK MPD ")
36 | return conn, nil
37 | }
38 |
39 | func (c *conn) Write(p []byte) (n int, err error) {
40 | return c.conn.Write(p)
41 | }
42 |
43 | func (c *conn) Close() error {
44 | // log.Println("TRACE", "close")
45 | return c.conn.Close()
46 | }
47 |
48 | func (c *conn) SetDeadline(t time.Time) error {
49 | return c.conn.SetDeadline(t)
50 | }
51 |
--------------------------------------------------------------------------------
/internal/log/testing.go:
--------------------------------------------------------------------------------
1 | package log
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | )
7 |
8 | type TestLogger struct {
9 | tb testing.TB
10 | }
11 |
12 | func NewTestLogger(tb testing.TB) *TestLogger {
13 | return &TestLogger{tb}
14 | }
15 |
16 | func (l *TestLogger) Printf(format string, v ...interface{}) {
17 | l.tb.Helper()
18 | l.tb.Logf(format, v...)
19 | }
20 |
21 | func (l *TestLogger) Println(v ...interface{}) {
22 | l.tb.Helper()
23 | l.tb.Log(fmt.Sprintln(v...))
24 | }
25 |
26 | func (l *TestLogger) Print(v ...interface{}) {
27 | l.tb.Helper()
28 | l.tb.Log(fmt.Sprint(v...))
29 | }
30 |
31 | func (l *TestLogger) Debugf(format string, v ...interface{}) {
32 | l.tb.Helper()
33 | l.tb.Logf("debug: "+format, v...)
34 | }
35 |
36 | func (l *TestLogger) Debugln(v ...interface{}) {
37 | l.tb.Helper()
38 | l.tb.Log("debug: " + fmt.Sprintln(v...))
39 | }
40 |
41 | func (l *TestLogger) Debug(v ...interface{}) {
42 | l.tb.Helper()
43 | l.tb.Log("debug: " + fmt.Sprint(v...))
44 | }
45 |
46 | func (l *TestLogger) Fatalf(format string, v ...interface{}) {
47 | l.tb.Helper()
48 | l.tb.Fatalf(format, v...)
49 | }
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 meiraka
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: test
2 | on:
3 | push:
4 | branches: [master]
5 | pull_request:
6 | branches: [master]
7 | jobs:
8 | go:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | go: [ '1.19', '1.20' ]
13 | name: Test Go ${{ matrix.go }}
14 | steps:
15 | - name: Set up Go ${{ matrix.go }}
16 | uses: actions/setup-go@v1
17 | with:
18 | go-version: ${{ matrix.go }}
19 | id: go
20 | - name: Check out code into the Go module directory
21 | uses: actions/checkout@v2
22 | - name: gofmt
23 | run: test -z "$(gofmt -s -l . | tee /dev/stderr)"
24 | - name: staticcheck
25 | uses: reviewdog/action-staticcheck@v1
26 | with:
27 | fail_on_error: true
28 | filter_mode: nofilter
29 | reporter: github-check
30 | - name: go vet
31 | run: go vet ./...
32 | - name: go test
33 | run: go test -race -v ./...
34 | - name: go build
35 | run: make all
36 | node:
37 | runs-on: ubuntu-latest
38 | steps:
39 | - uses: actions/checkout@v2
40 | - name: Install modules
41 | run: yarn
42 | - name: Run ESLint
43 | run: yarn lint
44 |
--------------------------------------------------------------------------------
/internal/vv/assets/nocover.svg:
--------------------------------------------------------------------------------
1 |
27 |
--------------------------------------------------------------------------------
/internal/vv/api/currentsong.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | )
7 |
8 | type MPDCurrentSong interface {
9 | CurrentSong(context.Context) (map[string][]string, error)
10 | }
11 |
12 | type CurrentSongHandler struct {
13 | mpd MPDCurrentSong
14 | cache *cache
15 | songHook func(map[string][]string) map[string][]string
16 | }
17 |
18 | func NewCurrentSongHandler(mpd MPDCurrentSong, songHook func(map[string][]string) map[string][]string) (*CurrentSongHandler, error) {
19 | c, err := newCache(map[string][]string{})
20 | if err != nil {
21 | return nil, err
22 | }
23 | return &CurrentSongHandler{
24 | mpd: mpd,
25 | cache: c,
26 | songHook: songHook,
27 | }, nil
28 | }
29 |
30 | func (a *CurrentSongHandler) Update(ctx context.Context) error {
31 | l, err := a.mpd.CurrentSong(ctx)
32 | if err != nil {
33 | return err
34 | }
35 | _, err = a.cache.SetIfModified(a.songHook(l))
36 | return err
37 | }
38 |
39 | func (a *CurrentSongHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
40 | a.cache.ServeHTTP(w, r)
41 | }
42 |
43 | func (a *CurrentSongHandler) Changed() <-chan struct{} {
44 | return a.cache.Changed()
45 | }
46 |
47 | func (a *CurrentSongHandler) Close() {
48 | a.cache.Close()
49 | }
50 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ==
2 | vv
3 | ==
4 |
5 | .. image:: https://github.com/meiraka/vv/workflows/test/badge.svg
6 | :target: https://github.com/meiraka/vv/actions
7 |
8 | Web App client for Music Player Daemon
9 |
10 | .. image:: appendix/screenshot.jpg
11 | :alt: screenshot
12 |
13 |
14 | Installation
15 | ============
16 |
17 | .. code-block:: shell
18 |
19 | go install github.com/meiraka/vv@latest
20 |
21 | Or get pre-built binary from `GitHub Releases page `_ and extract to somewhere you want.
22 |
23 | Options
24 | =======
25 |
26 | .. code-block:: shell
27 |
28 | -d, --debug use local assets if exists
29 | --mpd.addr string mpd server address to connect
30 | --mpd.binarylimit string set the maximum binary response size of mpd
31 | --mpd.conf string set mpd.conf path to get music_directory and http audio output
32 | --mpd.music_directory string set music_directory in mpd.conf value to search album cover image
33 | --mpd.network string mpd server network to connect
34 | --server.addr string this app serving address
35 | --server.cover.remote enable coverart via mpd api
36 |
37 | Configuration
38 | =============
39 |
40 | put `config.yaml <./appendix/example.config.yaml>`_ to /etc/xdg/vv/ or ~/.config/vv/
41 |
--------------------------------------------------------------------------------
/internal/vv/api/helpers_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "errors"
5 | "testing"
6 | )
7 |
8 | var errTest = errors.New("api_test: test error")
9 |
10 | func recieveMsg(c <-chan struct{}) bool {
11 | select {
12 | case <-c:
13 | return true
14 | default:
15 | return false
16 | }
17 | }
18 |
19 | func mockBoolFunc(f string, want bool, ret error) func(t *testing.T, b bool) error {
20 | return func(t *testing.T, got bool) error {
21 | t.Helper()
22 | if got != want {
23 | t.Errorf("called "+f+"; want "+f, got, want)
24 | }
25 | return ret
26 | }
27 | }
28 |
29 | func mockIntFunc(f string, want int, ret error) func(t *testing.T, b int) error {
30 | return func(t *testing.T, got int) error {
31 | t.Helper()
32 | if got != want {
33 | t.Errorf("called "+f+"; want "+f, got, want)
34 | }
35 | return ret
36 | }
37 | }
38 |
39 | func mockStringFunc(f string, want string, ret error) func(t *testing.T, b string) error {
40 | return func(t *testing.T, got string) error {
41 | t.Helper()
42 | if got != want {
43 | t.Errorf("called "+f+"; want "+f, got, want)
44 | }
45 | return ret
46 | }
47 | }
48 |
49 | func mockFloat64Func(f string, want float64, ret error) func(t *testing.T, b float64) error {
50 | return func(t *testing.T, got float64) error {
51 | t.Helper()
52 | if got != want {
53 | t.Errorf("called "+f+"; want "+f, got, want)
54 | }
55 | return ret
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
2 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
3 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
4 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
5 | go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
6 | go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
7 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
8 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
9 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
10 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
11 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
12 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
13 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
16 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
17 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
18 |
--------------------------------------------------------------------------------
/internal/mpd/request.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "strconv"
7 | "strings"
8 | )
9 |
10 | func request(w io.Writer, cmd string, args ...interface{}) error {
11 | if _, err := io.WriteString(w, cmd); err != nil {
12 | return err
13 | }
14 | for i := range args {
15 | if _, err := io.WriteString(w, " "); err != nil {
16 | return err
17 | }
18 | switch v := args[i].(type) {
19 | case string:
20 | if _, err := io.WriteString(w, quote(v)); err != nil {
21 | return err
22 | }
23 | case bool:
24 | if _, err := io.WriteString(w, btoa(v, "1", "0")); err != nil {
25 | return err
26 | }
27 | case int:
28 | if _, err := io.WriteString(w, strconv.Itoa(v)); err != nil {
29 | return err
30 | }
31 | case float64:
32 | if _, err := io.WriteString(w, strconv.FormatFloat(v, 'g', -1, 64)); err != nil {
33 | return err
34 | }
35 | default:
36 | return fmt.Errorf("mpd: fixme: unsupported arguments type: %#v", v)
37 | }
38 | }
39 | _, err := io.WriteString(w, "\n")
40 | return err
41 | }
42 |
43 | func srequest(cmd string, args ...interface{}) (string, error) {
44 | b := &strings.Builder{}
45 | if err := request(b, cmd, args...); err != nil {
46 | return "", err
47 | }
48 | return b.String(), nil
49 | }
50 |
51 | var quoter = strings.NewReplacer(
52 | "\\", "\\\\",
53 | `"`, `\"`,
54 | "\n", "",
55 | )
56 |
57 | // quote escaping strings values for mpd.
58 | func quote(s string) string {
59 | return `"` + quoter.Replace(s) + `"`
60 | }
61 |
62 | func btoa(s bool, t string, f string) string {
63 | if s {
64 | return t
65 | }
66 | return f
67 | }
68 |
--------------------------------------------------------------------------------
/internal/mpd/watcher_test.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/meiraka/vv/internal/mpd/mpdtest"
9 | )
10 |
11 | const (
12 | testTimeout = 10 * time.Second
13 | )
14 |
15 | func TestWatcher(t *testing.T) {
16 | ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
17 | defer cancel()
18 | ts := mpdtest.NewServer("OK MPD 0.19")
19 | defer ts.Close()
20 | w, err := NewWatcher("tcp", ts.URL,
21 | &WatcherOptions{Timeout: testTimeout, ReconnectionInterval: time.Millisecond})
22 | if err != nil {
23 | t.Fatalf("Dial got error %v; want nil", err)
24 | }
25 | ts.Expect(ctx, &mpdtest.WR{Read: "idle\n", Write: "changed: playlist\nchanged: player\nOK\n"})
26 | got, ok := readChan(ctx, t, w.Event())
27 | if want := "playlist"; !ok || got != want {
28 | t.Fatalf("got client %s, %v; want %s, true", got, ok, want)
29 | }
30 | got, ok = readChan(ctx, t, w.Event())
31 | if want := "player"; !ok || got != want {
32 | t.Fatalf("got client %s, %v; want %s, true", got, ok, want)
33 | }
34 | ts.Expect(ctx, &mpdtest.WR{Read: "idle\n"})
35 | errs := make(chan error, 1)
36 | go func() { errs <- w.Close(ctx) }()
37 |
38 | ts.Expect(ctx, &mpdtest.WR{Read: "noidle\n", Write: "OK\n"})
39 | if err := <-errs; err != nil {
40 | t.Errorf("Close got error %v; want nil", err)
41 | }
42 | got, ok = readChan(ctx, t, w.Event())
43 | if ok {
44 | t.Errorf("got \"%s\", %v; want \"\", false", got, ok)
45 | }
46 |
47 | }
48 |
49 | func readChan(ctx context.Context, t *testing.T, c <-chan string) (ret string, ok bool) {
50 | t.Helper()
51 | select {
52 | case ret, ok = <-c:
53 | case <-ctx.Done():
54 | t.Fatalf("read timeout %v", ctx.Err())
55 | }
56 | return
57 | }
58 |
--------------------------------------------------------------------------------
/internal/vv/api/version.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "runtime"
7 | )
8 |
9 | var goVersion = fmt.Sprintf("%s %s %s", runtime.Version(), runtime.GOOS, runtime.GOARCH)
10 |
11 | type httpVersion struct {
12 | App string `json:"app"`
13 | Go string `json:"go"`
14 | MPD string `json:"mpd"`
15 | }
16 |
17 | type VersionHandler struct {
18 | mpd MPDVersion
19 | cache *cache
20 | version string
21 | }
22 |
23 | // MPDVersion represents mpd api for Version API.
24 | type MPDVersion interface {
25 | Version() string
26 | }
27 |
28 | func NewVersionHandler(mpd MPDVersion, version string) (*VersionHandler, error) {
29 | c, err := newCache(map[string]*httpVersion{})
30 | if err != nil {
31 | return nil, err
32 | }
33 | return &VersionHandler{
34 | mpd: mpd,
35 | cache: c,
36 | version: version,
37 | }, nil
38 | }
39 |
40 | func (a *VersionHandler) Update() error {
41 | mpdVersion := a.mpd.Version()
42 | if len(mpdVersion) == 0 {
43 | mpdVersion = "unknown"
44 | }
45 | _, err := a.cache.SetIfModified(&httpVersion{App: a.version, Go: goVersion, MPD: mpdVersion})
46 | return err
47 | }
48 |
49 | func (a *VersionHandler) UpdateNoMPD() error {
50 | _, err := a.cache.SetIfModified(&httpVersion{App: a.version, Go: goVersion})
51 | return err
52 | }
53 |
54 | // ServeHTTP responses version as json format.
55 | func (a *VersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
56 | a.cache.ServeHTTP(w, r)
57 | }
58 |
59 | // Changed returns version update event chan.
60 | func (a *VersionHandler) Changed() <-chan struct{} {
61 | return a.cache.Changed()
62 | }
63 |
64 | // Close closes update event chan.
65 | func (a *VersionHandler) Close() {
66 | a.cache.Close()
67 | }
68 |
--------------------------------------------------------------------------------
/internal/vv/api/library.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "net/http"
8 | "time"
9 | )
10 |
11 | type httpLibraryInfo struct {
12 | Updating bool `json:"updating"`
13 | }
14 |
15 | type MPDLibrary interface {
16 | Update(context.Context, string) (map[string]string, error)
17 | }
18 |
19 | type LibraryHandler struct {
20 | mpd MPDLibrary
21 | cache *cache
22 | }
23 |
24 | func NewLibraryHandler(mpd MPDLibrary) (*LibraryHandler, error) {
25 | c, err := newCache(&httpLibraryInfo{})
26 | if err != nil {
27 | return nil, err
28 | }
29 | return &LibraryHandler{
30 | mpd: mpd,
31 | cache: c,
32 | }, nil
33 | }
34 |
35 | func (a *LibraryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
36 | if r.Method != "POST" {
37 | a.cache.ServeHTTP(w, r)
38 | return
39 | }
40 | var req httpLibraryInfo
41 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
42 | writeHTTPError(w, http.StatusBadRequest, err)
43 | return
44 | }
45 | if !req.Updating {
46 | writeHTTPError(w, http.StatusBadRequest, errors.New("requires updating=true"))
47 | return
48 | }
49 | ctx := r.Context()
50 | now := time.Now().UTC()
51 | if _, err := a.mpd.Update(ctx, ""); err != nil {
52 | writeHTTPError(w, http.StatusInternalServerError, err)
53 | return
54 | }
55 | r.Method = http.MethodGet
56 | a.cache.ServeHTTP(w, setUpdateTime(r, now))
57 | }
58 |
59 | func (a *LibraryHandler) UpdateStatus(updating bool) error {
60 | _, err := a.cache.SetIfModified(&httpLibraryInfo{Updating: updating})
61 | return err
62 | }
63 |
64 | // Changed returns library song list update event chan.
65 | func (a *LibraryHandler) Changed() <-chan struct{} {
66 | return a.cache.Changed()
67 | }
68 |
69 | // Close closes update event chan.
70 | func (a *LibraryHandler) Close() {
71 | a.cache.Close()
72 | }
73 |
--------------------------------------------------------------------------------
/internal/vv/api/neighbors.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 |
8 | "github.com/meiraka/vv/internal/mpd"
9 | )
10 |
11 | // NeighborsHandler provides neighbor storage name and uri.
12 | type NeighborsHandler struct {
13 | mpd interface {
14 | ListNeighbors(context.Context) ([]map[string]string, error)
15 | }
16 | cache *cache
17 | logger Logger
18 | }
19 |
20 | // NewNeighborsHandler initilize Neighbors cache with mpd connection.
21 | func NewNeighborsHandler(mpd interface {
22 | ListNeighbors(context.Context) ([]map[string]string, error)
23 | }, logger Logger) (*NeighborsHandler, error) {
24 | c, err := newCache(map[string]*httpStorage{})
25 | if err != nil {
26 | return nil, err
27 | }
28 | return &NeighborsHandler{
29 | mpd: mpd,
30 | cache: c,
31 | logger: logger,
32 | }, nil
33 | }
34 |
35 | // Update updates neighbors list.
36 | func (a *NeighborsHandler) Update(ctx context.Context) error {
37 | ret := map[string]*httpStorage{}
38 | ms, err := a.mpd.ListNeighbors(ctx)
39 | if err != nil {
40 | // skip command error to support old mpd
41 | var perr *mpd.CommandError
42 | if errors.As(err, &perr) {
43 | a.cache.SetIfModified(ret)
44 | a.logger.Debugf("vv/api: neighbors: %v", err)
45 | return nil
46 | }
47 | return err
48 | }
49 | for _, m := range ms {
50 | ret[m["name"]] = &httpStorage{
51 | URI: stringPtr(m["neighbor"]),
52 | }
53 | }
54 | a.cache.SetIfModified(ret)
55 | return nil
56 | }
57 |
58 | // ServeHTTP responses neighbors list as json format.
59 | func (a *NeighborsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
60 | a.cache.ServeHTTP(w, r)
61 | }
62 |
63 | // Changed returns neighbors list update event chan.
64 | func (a *NeighborsHandler) Changed() <-chan struct{} {
65 | return a.cache.Changed()
66 | }
67 |
68 | // Close closes update event chan.
69 | func (a *NeighborsHandler) Close() {
70 | a.cache.Close()
71 | }
72 |
--------------------------------------------------------------------------------
/internal/vv/api/outputs_stream.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "io"
6 | "net/http"
7 | "sync"
8 | )
9 |
10 | // OutputsStreamHandler is a MPD HTTP audio proxy.
11 | type OutputsStreamHandler struct {
12 | proxy map[string]string
13 | stopCh chan struct{}
14 | stopMu sync.Mutex
15 | stopB bool
16 | logger Logger
17 | }
18 |
19 | // NewOutputsStreamHandler initilize OutputsStreamHandler cache with mpd connection.
20 | func NewOutputsStreamHandler(proxy map[string]string, logger Logger) (*OutputsStreamHandler, error) {
21 | return &OutputsStreamHandler{
22 | proxy: proxy,
23 | stopCh: make(chan struct{}),
24 | logger: logger,
25 | }, nil
26 | }
27 |
28 | // ServeHTTP responses audio stream.
29 | func (a *OutputsStreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
30 | dev := r.URL.Query().Get("name")
31 | url, ok := a.proxy[dev]
32 | if !ok {
33 | http.NotFound(w, r)
34 | return
35 | }
36 | ctx, cancel := context.WithCancel(r.Context())
37 | defer cancel()
38 | pr, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
39 | if err != nil {
40 | a.logger.Println("vv/api: stream:", url, err)
41 | w.WriteHeader(http.StatusInternalServerError)
42 | return
43 | }
44 | resp, err := http.DefaultClient.Do(pr)
45 | if err != nil {
46 | a.logger.Println("vv/api: stream:", url, err)
47 | w.WriteHeader(http.StatusBadGateway)
48 | return
49 | }
50 | defer resp.Body.Close()
51 | for k, v := range resp.Header {
52 | for i := range v {
53 | w.Header().Add(k, v[i])
54 | }
55 | }
56 | go func() {
57 | select {
58 | case <-ctx.Done():
59 | case <-a.stopCh:
60 | // disconnect audio stream by stop()
61 | cancel()
62 | }
63 | }()
64 | io.Copy(w, resp.Body)
65 | }
66 |
67 | // Stop closes audio streams.
68 | func (a *OutputsStreamHandler) Stop() {
69 | a.stopMu.Lock()
70 | if !a.stopB {
71 | a.stopB = true
72 | close(a.stopCh)
73 | }
74 | a.stopMu.Unlock()
75 | }
76 |
--------------------------------------------------------------------------------
/internal/vv/api/library_songs.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "sync"
7 | )
8 |
9 | type MPDLibrarySongs interface {
10 | ListAllInfo(context.Context, string) ([]map[string][]string, error)
11 | }
12 |
13 | func NewLibrarySongsHandler(mpd MPDLibrarySongs, songsHook func([]map[string][]string) []map[string][]string) (*LibrarySongsHandler, error) {
14 | cache, err := newCache([]map[string][]string{})
15 | if err != nil {
16 | return nil, err
17 | }
18 | return &LibrarySongsHandler{
19 | mpd: mpd,
20 | cache: cache,
21 | changed: make(chan struct{}, cap(cache.Changed())),
22 | songsHook: songsHook,
23 | }, nil
24 |
25 | }
26 |
27 | type LibrarySongsHandler struct {
28 | mpd MPDLibrarySongs
29 | cache *cache
30 | changed chan struct{}
31 | songsHook func([]map[string][]string) []map[string][]string
32 | data []map[string][]string
33 | mu sync.RWMutex
34 | }
35 |
36 | func (a *LibrarySongsHandler) Update(ctx context.Context) error {
37 | l, err := a.mpd.ListAllInfo(ctx, "/")
38 | if err != nil {
39 | return err
40 | }
41 | v := a.songsHook(l)
42 | // force update to skip []byte compare
43 | if err := a.cache.Set(v); err != nil {
44 | return err
45 | }
46 | a.mu.Lock()
47 | a.data = v
48 | a.mu.Unlock()
49 | select {
50 | case a.changed <- struct{}{}:
51 | default:
52 | }
53 | return nil
54 | }
55 |
56 | func (a *LibrarySongsHandler) Cache() []map[string][]string {
57 | a.mu.RLock()
58 | defer a.mu.RUnlock()
59 | return a.data
60 | }
61 |
62 | // ServeHTTP responses library song list as json format.
63 | func (a *LibrarySongsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
64 | a.cache.ServeHTTP(w, r)
65 | }
66 |
67 | // Changed returns library song list update event chan.
68 | func (a *LibrarySongsHandler) Changed() <-chan struct{} {
69 | return a.changed
70 | }
71 |
72 | // Close closes update event chan.
73 | func (a *LibrarySongsHandler) Close() {
74 | a.cache.Close()
75 | }
76 |
--------------------------------------------------------------------------------
/internal/vv/api/playlist_songs.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "sync"
7 | )
8 |
9 | type MPDPlaylistSongs interface {
10 | PlaylistInfo(context.Context) ([]map[string][]string, error)
11 | }
12 |
13 | type PlaylistSongsHandler struct {
14 | mpd MPDPlaylistSongs
15 | cache *cache
16 | changed chan struct{}
17 | songsHook func([]map[string][]string) []map[string][]string
18 | data []map[string][]string
19 | mu sync.RWMutex
20 | }
21 |
22 | func NewPlaylistSongsHandler(mpd MPDPlaylistSongs, songsHook func([]map[string][]string) []map[string][]string) (*PlaylistSongsHandler, error) {
23 | cache, err := newCache([]map[string][]string{})
24 | if err != nil {
25 | return nil, err
26 | }
27 | return &PlaylistSongsHandler{
28 | mpd: mpd,
29 | cache: cache,
30 | changed: make(chan struct{}, cap(cache.Changed())),
31 | songsHook: songsHook,
32 | }, nil
33 |
34 | }
35 |
36 | func (a *PlaylistSongsHandler) Update(ctx context.Context) error {
37 | l, err := a.mpd.PlaylistInfo(ctx)
38 | if err != nil {
39 | return err
40 | }
41 | v := a.songsHook(l)
42 | changed, err := a.cache.SetIfModified(v)
43 | if err != nil {
44 | return err
45 | }
46 | a.mu.Lock()
47 | a.data = v
48 | a.mu.Unlock()
49 | if changed {
50 | select {
51 | case a.changed <- struct{}{}:
52 | default:
53 | }
54 | }
55 | return nil
56 | }
57 |
58 | func (a *PlaylistSongsHandler) Cache() []map[string][]string {
59 | a.mu.RLock()
60 | defer a.mu.RUnlock()
61 | return a.data
62 | }
63 |
64 | // ServeHTTP responses neighbors list as json format.
65 | func (a *PlaylistSongsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
66 | a.cache.ServeHTTP(w, r)
67 | }
68 |
69 | // Changed returns neighbors list update event chan.
70 | func (a *PlaylistSongsHandler) Changed() <-chan struct{} {
71 | return a.changed
72 | }
73 |
74 | // Close closes update event chan.
75 | func (a *PlaylistSongsHandler) Close() {
76 | a.cache.Close()
77 | }
78 |
--------------------------------------------------------------------------------
/internal/vv/api/outputs_stream_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "sync"
7 | "testing"
8 |
9 | "github.com/meiraka/vv/internal/log"
10 | "github.com/meiraka/vv/internal/vv/api"
11 | )
12 |
13 | func TestOutputsStreamHandlerGET(t *testing.T) {
14 | normal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15 | }))
16 | defer normal.Close()
17 | slowconn := make(chan struct{}, 1)
18 | slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 | slowconn <- struct{}{}
20 | }))
21 | defer slow.Close()
22 | h, err := api.NewOutputsStreamHandler(map[string]string{
23 | "normal": normal.URL,
24 | "slow": slow.URL,
25 | }, log.NewTestLogger(t))
26 | if err != nil {
27 | t.Fatalf("failed to init OutputsStreamHandler: %v", err)
28 | }
29 | for _, tt := range []struct {
30 | label string
31 | url string
32 | postHook func()
33 | status int
34 | want string
35 | }{
36 | {
37 | label: "ok",
38 | url: "/?name=normal",
39 | status: http.StatusOK,
40 | want: "",
41 | },
42 | {
43 | label: "not found",
44 | url: "/?name=notfound",
45 | status: http.StatusNotFound,
46 | want: "404 page not found\n",
47 | },
48 | {
49 | label: "stop",
50 | url: "/?name=slow",
51 | status: http.StatusOK,
52 | want: "",
53 | postHook: func() {
54 | <-slowconn
55 | h.Stop()
56 | },
57 | },
58 | } {
59 | t.Run(tt.label, func(t *testing.T) {
60 | var wg sync.WaitGroup
61 | wg.Add(1)
62 | go func() {
63 | defer wg.Done()
64 | r := httptest.NewRequest(http.MethodGet, tt.url, nil)
65 | w := httptest.NewRecorder()
66 | h.ServeHTTP(w, r)
67 | if status, got := w.Result().StatusCode, w.Body.String(); status != tt.status || got != tt.want {
68 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, tt.status, tt.want)
69 | }
70 | }()
71 | if tt.postHook != nil {
72 | tt.postHook()
73 | }
74 | wg.Wait()
75 |
76 | })
77 | }
78 |
79 | }
80 |
--------------------------------------------------------------------------------
/internal/log/log.go:
--------------------------------------------------------------------------------
1 | package log
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "log"
7 | "os"
8 | )
9 |
10 | type Logger struct {
11 | l *log.Logger
12 | debug bool
13 | }
14 |
15 | func New(out io.Writer) *Logger {
16 | return &Logger{
17 | l: log.New(out, "", log.LstdFlags),
18 | }
19 | }
20 |
21 | func NewDebugLogger(out io.Writer) *Logger {
22 | return &Logger{
23 | l: log.New(out, "", log.Lshortfile|log.LstdFlags),
24 | debug: true,
25 | }
26 | }
27 |
28 | // Printf calls l.Output to print to the logger.
29 | // Arguments are handled in the manner of fmt.Printf.
30 | func (l *Logger) Printf(format string, v ...interface{}) {
31 | l.l.Output(2, fmt.Sprintf(format, v...))
32 | }
33 |
34 | // Print calls l.Output to print to the logger.
35 | // Arguments are handled in the manner of fmt.Print.
36 | func (l *Logger) Print(v ...interface{}) { l.l.Output(2, fmt.Sprint(v...)) }
37 |
38 | // Println calls l.Output to print to the logger.
39 | // Arguments are handled in the manner of fmt.Println.
40 | func (l *Logger) Println(v ...interface{}) { l.l.Output(2, fmt.Sprintln(v...)) }
41 |
42 | // Debugf calls l.Output to print to the logger.
43 | // Arguments are handled in the manner of fmt.Printf.
44 | func (l *Logger) Debugf(format string, v ...interface{}) {
45 | if !l.debug {
46 | return
47 | }
48 | l.l.Output(2, "debug: "+fmt.Sprintf(format, v...))
49 | }
50 |
51 | // Debug calls l.Output to print to the logger.
52 | // Arguments are handled in the manner of fmt.Print.
53 | func (l *Logger) Debug(v ...interface{}) {
54 | if !l.debug {
55 | return
56 | }
57 | l.l.Output(2, "debug: "+fmt.Sprint(v...))
58 | }
59 |
60 | // Debugln calls l.Output to print to the logger.
61 | // Arguments are handled in the manner of fmt.Println.
62 | func (l *Logger) Debugln(v ...interface{}) {
63 | if !l.debug {
64 | return
65 | }
66 | l.l.Output(2, "debug: "+fmt.Sprintln(v...))
67 | }
68 |
69 | // Fatalf is equivalent to l.Printf() followed by a call to os.Exit(1).
70 | func (l *Logger) Fatalf(format string, v ...interface{}) {
71 | l.l.Output(2, fmt.Sprintf(format, v...))
72 | os.Exit(1)
73 | }
74 |
--------------------------------------------------------------------------------
/internal/mpd/error.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | )
7 |
8 | // Predefined error codes in https://github.com/MusicPlayerDaemon/MPD/blob/master/src/protocol/Ack.hxx
9 | const (
10 | ErrNotList AckError = 1
11 |
12 | ErrArg AckError = 2
13 | ErrPassword AckError = 3
14 | ErrPermission AckError = 4
15 | ErrUnknown AckError = 5
16 |
17 | ErrNoExist AckError = 50
18 | ErrPlaylistMax AckError = 51
19 | ErrSystem AckError = 52
20 | ErrPlaylistLoad AckError = 53
21 | ErrUpdateAlready AckError = 54
22 | ErrPlayerSync AckError = 55
23 | ErrExist AckError = 56
24 | )
25 |
26 | // AckError is the numeric value in CommandError.
27 | type AckError int
28 |
29 | func (a AckError) Error() string {
30 | switch a {
31 | case 1:
32 | return "ErrNotList"
33 | case 2:
34 | return "ErrArg"
35 | case 3:
36 | return "ErrPassword"
37 | case 4:
38 | return "ErrPermission"
39 | case 5:
40 | return "ErrUnknown"
41 | case 50:
42 | return "ErrNoExist"
43 | case 51:
44 | return "ErrPlaylistMax"
45 | case 52:
46 | return "ErrSystem"
47 | case 53:
48 | return "ErrPlaylistLoad"
49 | case 54:
50 | return "ErrUpdateAlready"
51 | case 55:
52 | return "ErrPlayerSync"
53 | case 56:
54 | return "ErrExist"
55 | }
56 | return ""
57 | }
58 |
59 | // CommandError represents mpd command error.
60 | type CommandError struct {
61 | ID AckError
62 | Index int
63 | Command string
64 | Message string
65 | }
66 |
67 | func (f *CommandError) Error() string {
68 | if len(f.Command) == 0 {
69 | return fmt.Sprintf("mpd: %s", f.Message)
70 | }
71 | return fmt.Sprintf("mpd: %s: %s", f.Command, f.Message)
72 | }
73 |
74 | // Unwrap returns AckError.
75 | func (f *CommandError) Unwrap() error {
76 | return f.ID
77 | }
78 |
79 | // Is returns true if pointer or all values are same.
80 | func (f *CommandError) Is(target error) bool {
81 | if target == f {
82 | return true
83 | }
84 | var t *CommandError
85 | if !errors.As(target, &t) {
86 | return false
87 | }
88 | return f.ID == t.ID &&
89 | f.Index == t.Index &&
90 | f.Command == t.Command &&
91 | f.Message == t.Message
92 | }
93 |
--------------------------------------------------------------------------------
/internal/vv/tree.go:
--------------------------------------------------------------------------------
1 | package vv
2 |
3 | // Tree is a vv playlist view definition.
4 | type Tree map[string]*TreeNode
5 |
6 | // TreeNode represents one of smart playlist node.
7 | type TreeNode struct {
8 | Sort []string `json:"sort"`
9 | Tree [][2]string `json:"tree"`
10 | }
11 |
12 | var (
13 | // DefaultTree is a default Tree for HTMLConfig.
14 | DefaultTree = Tree{
15 | "AlbumArtist": {
16 | Sort: []string{"AlbumArtist", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"},
17 | Tree: [][2]string{{"AlbumArtist", "plain"}, {"Album", "album"}, {"Title", "song"}},
18 | },
19 | "Album": {
20 | Sort: []string{"AlbumArtist-Date-Album", "DiscNumber", "TrackNumber", "Title", "file"},
21 | Tree: [][2]string{{"AlbumArtist-Date-Album", "album"}, {"Title", "song"}},
22 | },
23 | "Artist": {
24 | Sort: []string{"Artist", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"},
25 | Tree: [][2]string{{"Artist", "plain"}, {"Title", "song"}},
26 | },
27 | "Genre": {
28 | Sort: []string{"Genre", "Album", "DiscNumber", "TrackNumber", "Title", "file"},
29 | Tree: [][2]string{{"Genre", "plain"}, {"Album", "album"}, {"Title", "song"}},
30 | },
31 | "Date": {
32 | Sort: []string{"Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"},
33 | Tree: [][2]string{{"Date", "plain"}, {"Album", "album"}, {"Title", "song"}},
34 | },
35 | "Composer": {
36 | Sort: []string{"Composer", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"},
37 | Tree: [][2]string{{"Composer", "plain"}, {"Album", "album"}, {"Title", "song"}},
38 | },
39 | "Performer": {
40 | Sort: []string{"Performer", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"},
41 | Tree: [][2]string{{"Performer", "plain"}, {"Album", "album"}, {"Title", "song"}},
42 | },
43 | "LastModified": {
44 | Sort: []string{"LastModifiedDate", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"},
45 | Tree: [][2]string{{"LastModifiedDate", "plain"}, {"Album", "album"}, {"Title", "song"}},
46 | },
47 | }
48 | // DefaultTreeOrder is a default TreeOrder for HTMLConfig.
49 | DefaultTreeOrder = []string{"AlbumArtist", "Album", "Artist", "Genre", "Date", "Composer", "Performer", "LastModified"}
50 | )
51 |
--------------------------------------------------------------------------------
/internal/vv/api/images/local_test.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "net/http"
7 | "net/http/httptest"
8 | "os"
9 | "path/filepath"
10 | "reflect"
11 | "strconv"
12 | "testing"
13 | )
14 |
15 | func TestLocalCover(t *testing.T) {
16 | api, err := NewLocal("/foo", ".", []string{"app.png"})
17 | if err != nil {
18 | t.Fatalf("failed to initialize cover.Local: %v", err)
19 | }
20 | for _, tt := range []struct {
21 | in map[string][]string
22 | want []string
23 | wantHeader http.Header
24 | wantBinary []byte
25 | }{
26 | {
27 | in: map[string][]string{"file": {"testdata/test.flac"}},
28 | want: []string{"/foo/testdata/app.png?d=" + strconv.FormatInt(stat(t, filepath.Join("testdata", "app.png")).ModTime().Unix(), 10)},
29 | wantHeader: http.Header{"Content-Type": {"image/png"}, "Cache-Control": {"max-age=31536000"}},
30 | wantBinary: readFile(t, filepath.Join("testdata", "app.png")),
31 | },
32 | {
33 | in: map[string][]string{"file": {"notfound/test.flac"}},
34 | want: []string{},
35 | },
36 | } {
37 | t.Run(fmt.Sprint(tt.in), func(t *testing.T) {
38 | covers, _ := api.GetURLs(tt.in)
39 | if !reflect.DeepEqual(covers, tt.want) {
40 | t.Errorf("got GetURLs=%v; want %v", covers, tt.want)
41 | }
42 | if len(covers) == 0 {
43 | return
44 | }
45 | cover := covers[0]
46 | req := httptest.NewRequest("GET", cover, nil)
47 | w := httptest.NewRecorder()
48 | api.ServeHTTP(w, req)
49 | resp := w.Result()
50 | if resp.StatusCode != 200 {
51 | t.Errorf("got status %d; want 200", resp.StatusCode)
52 | }
53 | for k, v := range tt.wantHeader {
54 | if !reflect.DeepEqual(resp.Header[k], v) {
55 | t.Errorf("got header %s %v; want %v", k, resp.Header[k], v)
56 | }
57 | }
58 | got, _ := io.ReadAll(resp.Body)
59 | if !reflect.DeepEqual(got, tt.wantBinary) {
60 | t.Errorf("got invalid binary response")
61 | }
62 |
63 | })
64 | }
65 | }
66 |
67 | func readFile(t *testing.T, path string) []byte {
68 | t.Helper()
69 | b, err := os.ReadFile(path)
70 | if err != nil {
71 | t.Fatalf("failed to open file: %v", err)
72 | }
73 | return b
74 |
75 | }
76 | func stat(t *testing.T, path string) os.FileInfo {
77 | s, err := os.Stat(path)
78 | if err != nil {
79 | t.Fatalf("failed to stat file: %v", err)
80 | }
81 | return s
82 | }
83 |
--------------------------------------------------------------------------------
/internal/vv/assets/embed.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import (
4 | "crypto/md5"
5 | "embed"
6 | "encoding/hex"
7 | "fmt"
8 | "mime"
9 | "path"
10 | "strconv"
11 |
12 | "github.com/meiraka/vv/internal/gzip"
13 | )
14 |
15 | func init() {
16 | if err := initEmbed(); err != nil {
17 | panic(fmt.Errorf("assets: %w", err))
18 | }
19 | }
20 |
21 | //go:embed app-black.* app.* manifest.json nocover.svg w.png
22 | var embedFS embed.FS
23 | var (
24 | embedPath []string
25 | embedBody [][]byte
26 | embedLength []string
27 | embedHash []string
28 | embedGZBody [][]byte
29 | embedGZLength []string
30 | embedGZHash []string
31 | embedMine []string
32 | )
33 |
34 | func initEmbed() error {
35 | dir, err := embedFS.ReadDir(".")
36 | if err != nil {
37 | return fmt.Errorf("embed: readdir: %w", err)
38 | }
39 | length := len(dir)
40 | embedPath = make([]string, length)
41 | embedBody = make([][]byte, length)
42 | embedLength = make([]string, length)
43 | embedHash = make([]string, length)
44 | embedGZBody = make([][]byte, length)
45 | embedGZLength = make([]string, length)
46 | embedGZHash = make([]string, length)
47 | embedMine = make([]string, length)
48 |
49 | for i, f := range dir {
50 | if f.IsDir() {
51 | continue
52 | }
53 | embedPath[i] = path.Join("/", "assets", f.Name())
54 | m := mime.TypeByExtension(path.Ext(f.Name()))
55 | b, err := embedFS.ReadFile(f.Name())
56 | if err != nil {
57 | return fmt.Errorf("embed: readfile: %w", err)
58 | }
59 | embedBody[i] = b
60 | embedLength[i] = strconv.Itoa(len(b))
61 | hasher := md5.New()
62 | hasher.Write(b)
63 | embedHash[i] = hex.EncodeToString(hasher.Sum(nil))
64 | if m != "image/png" && m != "image/jpg" {
65 | gz, err := gzip.Encode(b)
66 | if err != nil {
67 | return fmt.Errorf("%s: gzip: %w", f.Name(), err)
68 | }
69 | embedGZBody[i] = gz
70 | embedGZLength[i] = strconv.Itoa(len(gz))
71 | hasher := md5.New()
72 | hasher.Write(gz)
73 | embedGZHash[i] = hex.EncodeToString(hasher.Sum(nil))
74 | }
75 | embedMine[i] = m
76 | }
77 | return nil
78 | }
79 |
80 | func embedIndex(path string) (index int, ok bool) {
81 | for i := range embedPath {
82 | if path == embedPath[i] {
83 | return i, true
84 | }
85 | }
86 | return -1, false
87 | }
88 |
89 | // Hash returns embeded assets file MD5 hash.
90 | func Hash(path string) (string, bool) {
91 | i, ok := embedIndex(path)
92 | if !ok {
93 | return "", false
94 | }
95 | return embedHash[i], true
96 | }
97 |
--------------------------------------------------------------------------------
/internal/vv/api/version_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/http"
7 | "net/http/httptest"
8 | "runtime"
9 | "testing"
10 |
11 | "github.com/meiraka/vv/internal/vv/api"
12 | )
13 |
14 | func TestVersionHandlerGET(t *testing.T) {
15 | goVersion := fmt.Sprintf("%s %s %s", runtime.Version(), runtime.GOOS, runtime.GOARCH)
16 | appVersion := "baz"
17 | for label, tt := range map[string]struct {
18 | version func() string
19 | err error
20 | want string
21 | changed bool
22 | update string
23 | }{
24 | "update": {
25 | version: func() string { return "foobar" },
26 | want: fmt.Sprintf(`{"app":"%s","go":"%s","mpd":"foobar"}`, appVersion, goVersion),
27 | changed: true,
28 | update: "Update",
29 | },
30 | "update/unknown": {
31 | version: func() string { return "" },
32 | want: fmt.Sprintf(`{"app":"%s","go":"%s","mpd":"unknown"}`, appVersion, goVersion),
33 | changed: true,
34 | update: "Update",
35 | },
36 | "update no mpd": {
37 | want: fmt.Sprintf(`{"app":"%s","go":"%s","mpd":""}`, appVersion, goVersion),
38 | changed: true,
39 | update: "UpdateNoMPD",
40 | },
41 | } {
42 | t.Run(label, func(t *testing.T) {
43 | mpd := &mpdVersion{t: t}
44 | h, err := api.NewVersionHandler(mpd, appVersion)
45 | if err != nil {
46 | t.Fatalf("failed to init Neighbors: %v", err)
47 | }
48 | defer h.Close()
49 | mpd.t = t
50 | mpd.version = tt.version
51 | switch tt.update {
52 | case "Update":
53 | if err := h.Update(); !errors.Is(err, tt.err) {
54 | t.Errorf("handler.Update() = %v; want %v", err, tt.err)
55 | }
56 | case "UpdateNoMPD":
57 | if err := h.UpdateNoMPD(); !errors.Is(err, tt.err) {
58 | t.Errorf("handler.UpdateNoMPD() = %v; want %v", err, tt.err)
59 | }
60 | }
61 | r := httptest.NewRequest(http.MethodGet, "/", nil)
62 | w := httptest.NewRecorder()
63 | h.ServeHTTP(w, r)
64 | if got, status, wantStatus := w.Body.String(), w.Result().StatusCode, http.StatusOK; got != tt.want || status != wantStatus {
65 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, wantStatus, tt.want)
66 | }
67 | if changed := recieveMsg(h.Changed()); changed != tt.changed {
68 | t.Errorf("changed = %v; want %v", changed, tt.changed)
69 | }
70 | })
71 | }
72 | }
73 |
74 | type mpdVersion struct {
75 | t *testing.T
76 | version func() string
77 | }
78 |
79 | func (m *mpdVersion) Version() string {
80 | m.t.Helper()
81 | if m.version == nil {
82 | m.t.Fatal("no Version mock function")
83 | }
84 | return m.version()
85 | }
86 |
--------------------------------------------------------------------------------
/internal/songs/tags.go:
--------------------------------------------------------------------------------
1 | package songs
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "strings"
7 | "time"
8 | )
9 |
10 | // AddTags adds tags to song for vv
11 | // TrackNumber, DiscNumber are used for sorting.
12 | // Length is used for displaing time.
13 | func AddTags(m map[string][]string) map[string][]string {
14 | track := getIntTag(m, "Track", 0)
15 | m["TrackNumber"] = []string{fmt.Sprintf("%04d", track)}
16 | disc := getIntTag(m, "Disc", 1)
17 | m["DiscNumber"] = []string{fmt.Sprintf("%04d", disc)}
18 | t := getIntTag(m, "Time", 0)
19 | m["Length"] = []string{fmt.Sprintf("%02d:%02d", t/60, t%60)}
20 | if l, ok := m["Last-Modified"]; ok && len(l) == 1 {
21 | if lt, err := time.Parse(time.RFC3339, l[0]); err == nil {
22 | m["LastModifiedDate"] = []string{lt.Format("2006.01.02")}
23 |
24 | }
25 | }
26 | return m
27 | }
28 |
29 | func getIntTag(m map[string][]string, k string, e int) int {
30 | if d, found := m[k]; found {
31 | ret, err := strconv.Atoi(d[0])
32 | if err == nil {
33 | return ret
34 | }
35 | }
36 | return e
37 | }
38 |
39 | // Tags returns "-" separated tags values in song.
40 | // returns nil if not found.
41 | func Tags(s map[string][]string, tags string) []string {
42 | keys := strings.Split(tags, "-")
43 | var ret []string
44 | for _, key := range keys {
45 | t := Tag(s, key)
46 | if ret == nil {
47 | ret = t
48 | } else if t != nil {
49 | newret := []string{}
50 | for _, old := range ret {
51 | for _, new := range t {
52 | newret = append(newret, old+"-"+new)
53 | }
54 | }
55 | ret = newret
56 | }
57 | }
58 | return ret
59 | }
60 |
61 | // Tag returns tag values in song.
62 | // returns nil if not found.
63 | func Tag(s map[string][]string, key string) []string {
64 | if v, found := s[key]; found {
65 | return v
66 | } else if key == "AlbumArtist" {
67 | return Tag(s, "Artist")
68 | } else if key == "AlbumSort" {
69 | return Tag(s, "Album")
70 | } else if key == "ArtistSort" {
71 | return Tag(s, "Artist")
72 | } else if key == "AlbumArtistSort" {
73 | return TagSearch(s, []string{"AlbumArtist", "Artist"})
74 | } else if key == "Date" {
75 | if v, found := s["OriginalDate"]; found {
76 | return v
77 | }
78 | } else if key == "OriginalDate" {
79 | if v, found := s["Date"]; found {
80 | return v
81 | }
82 | }
83 | return nil
84 | }
85 |
86 | // TagSearch searches tags in song.
87 | // returns nil if not found.
88 | func TagSearch(s map[string][]string, keys []string) []string {
89 | for i := range keys {
90 | key := keys[i]
91 | if _, ok := s[key]; ok {
92 | return s[key]
93 | }
94 | }
95 | return nil
96 | }
97 |
--------------------------------------------------------------------------------
/internal/mpd/commandlist.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | )
7 |
8 | // CommandListEqual compares command list a and b.
9 | func CommandListEqual(a, b *CommandList) bool {
10 | if a == nil && b == nil {
11 | return true
12 | }
13 | if a == nil || b == nil {
14 | return false
15 | }
16 | if len(a.requests) != len(b.requests) {
17 | return false
18 | }
19 | for i := range a.requests {
20 | if a.requests[i] != b.requests[i] {
21 | return false
22 | }
23 | }
24 | return true
25 | }
26 |
27 | // CommandList represents Client commandlist.
28 | type CommandList struct {
29 | requests []string
30 | commands []string
31 | parsers []func(*conn) error
32 | }
33 |
34 | const responseListOK = "list_OK"
35 |
36 | // Clear clears playlist
37 | func (cl *CommandList) Clear() {
38 | req, _ := srequest("clear")
39 | cl.requests = append(cl.requests, req)
40 | cl.commands = append(cl.commands, "clear")
41 | cl.parsers = append(cl.parsers, func(c *conn) error {
42 | return parseEnd(c, responseListOK)
43 | })
44 | }
45 |
46 | // Add adds uri to playlist.
47 | func (cl *CommandList) Add(uri string) {
48 | req, _ := srequest("add", uri)
49 | cl.requests = append(cl.requests, req)
50 | cl.commands = append(cl.commands, "add")
51 | cl.parsers = append(cl.parsers, func(c *conn) error {
52 | return parseEnd(c, responseListOK)
53 | })
54 | }
55 |
56 | // Play begins playing the playlist at song number pos.
57 | func (cl *CommandList) Play(pos int) {
58 | req, _ := srequest("play", pos)
59 | cl.requests = append(cl.requests, req)
60 | cl.commands = append(cl.commands, "play")
61 | cl.parsers = append(cl.parsers, func(c *conn) error {
62 | return parseEnd(c, responseListOK)
63 | })
64 | }
65 |
66 | // ExecCommandList executes commandlist.
67 | func (c *Client) ExecCommandList(ctx context.Context, cl *CommandList) error {
68 | defer func() {
69 | cl.requests = []string{}
70 | cl.commands = []string{}
71 | cl.parsers = []func(*conn) error{}
72 | }()
73 | return c.pool.Exec(ctx, func(conn *conn) error {
74 | if err := request(conn, "command_list_ok_begin"); err != nil {
75 | return err
76 | }
77 | for i := range cl.requests {
78 | if _, err := fmt.Fprint(conn, cl.requests[i]); err != nil {
79 | return err
80 | }
81 | }
82 | if err := request(conn, "command_list_end"); err != nil {
83 | return err
84 | }
85 | for i := range cl.parsers {
86 | if err := cl.parsers[i](conn); err != nil {
87 | return addCommandInfo(err, cl.commands[i])
88 | }
89 | }
90 | if err := parseEnd(conn, responseOK); err != nil {
91 | return addCommandInfo(err, "command_list_end")
92 | }
93 | return nil
94 | })
95 | }
96 |
--------------------------------------------------------------------------------
/appendix/example.config.yaml:
--------------------------------------------------------------------------------
1 | mpd:
2 | # mpd server network protocol to connect
3 | # default: tcp
4 | network: "tcp"
5 | # mpd server address to connect
6 | # default: localhost:6600
7 | addr: "localhost:6600"
8 | # set music_directory in mpd.conf value to search album cover image.
9 | # default: music_directory in mpd.conf if exists
10 | music_directory: "/path/to/music/dir"
11 | # mpd.conf path to get music_directory and http audio output.
12 | # default: /etc/mpd.conf
13 | conf: "/etc/mpd.conf"
14 | # set the maximum binary response size of mpd.
15 | # default: 8192
16 | # https://github.com/MusicPlayerDaemon/MPD/blob/995aafe9cc511430bff7a7a690df70998f4bb025/src/client/Client.hxx#L91
17 | binarylimit: 128 KiB
18 |
19 | server:
20 | # this app serving address
21 | # default: :8080
22 | addr: ":8080"
23 | # this app cache directory
24 | # default: https://golang.org/pkg/os/#TempDir + vv
25 | cache_directory: "/tmp/vv"
26 | cover:
27 | # search album cover image in mpd.music_directory
28 | # default: true
29 | local: true
30 | # search album cover image via mpd api
31 | # this feature uses server.cache_directory
32 | # default: false
33 | remote: true
34 |
35 | playlist:
36 | tree:
37 | AlbumArtist:
38 | # song order tags
39 | sort: ["AlbumArtist", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"]
40 | # defines playlist view:
41 | # left param: tag for grouping(must be defined in sort)
42 | # right param: view style(plain, album, song)
43 | tree:
44 | - ["AlbumArtist", "plain"]
45 | - ["Album", "album"]
46 | - ["Title", "song"]
47 | Album:
48 | # tags can be joined by "-"
49 | sort: ["Date-Album", "DiscNumber", "TrackNumber", "Title", "file"]
50 | tree: [["Date-Album", "album"], ["Title", "song"]]
51 | Artist:
52 | sort: ["Artist", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"]
53 | tree: [["Artist", "plain"], ["Title", "song"]]
54 | Genre:
55 | sort: ["Genre", "Album", "DiscNumber", "TrackNumber", "Title", "file"]
56 | tree: [["Genre", "plain"], ["Album", "album"], ["Title", "song"]]
57 | Date:
58 | sort: ["Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"]
59 | tree: [["Date", "plain"], ["Album", "album"], ["Title", "song"]]
60 | Composer:
61 | sort: ["Composer", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"]
62 | tree: [["Composer", "plain"], ["Album", "album"], ["Title", "song"]]
63 | Performer:
64 | sort: ["Performer", "Date", "Album", "DiscNumber", "TrackNumber", "Title", "file"]
65 | tree: [["Performer", "plain"], ["Album", "album"], ["Title", "song"]]
66 | tree_order: ["AlbumArtist", "Album", "Artist", "Genre", "Date", "Composer", "Performer"]
67 |
--------------------------------------------------------------------------------
/internal/vv/api/stats.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "strconv"
7 | )
8 |
9 | type httpMusicStats struct {
10 | Uptime int `json:"uptime"`
11 | Playtime int `json:"playtime"`
12 | Artists int `json:"artists"`
13 | Albums int `json:"albums"`
14 | Songs int `json:"songs"`
15 | LibraryPlaytime int `json:"library_playtime"`
16 | LibraryUpdate int `json:"library_update"`
17 | }
18 |
19 | type MPDStats interface {
20 | Stats(context.Context) (map[string]string, error)
21 | }
22 |
23 | // StatsHandler provides mpd stats.
24 | type StatsHandler struct {
25 | mpd MPDStats
26 | cache *cache
27 | }
28 |
29 | // NewStatsHandler initilize Stats cache with mpd connection.
30 | func NewStatsHandler(mpd MPDStats) (*StatsHandler, error) {
31 | c, err := newCache(&httpMusicStats{})
32 | if err != nil {
33 | return nil, err
34 | }
35 | return &StatsHandler{
36 | mpd: mpd,
37 | cache: c,
38 | }, nil
39 | }
40 |
41 | func (a *StatsHandler) Update(ctx context.Context) error {
42 | err := a.update(ctx)
43 | return err
44 | }
45 |
46 | func (a *StatsHandler) update(ctx context.Context) error {
47 | s, err := a.mpd.Stats(ctx)
48 | if err != nil {
49 | return err
50 | }
51 | ret := &httpMusicStats{}
52 | if _, ok := s["artists"]; ok {
53 | ret.Artists, err = strconv.Atoi(s["artists"])
54 | if err != nil {
55 | return err
56 | }
57 | }
58 | if _, ok := s["albums"]; ok {
59 | ret.Albums, err = strconv.Atoi(s["albums"])
60 | if err != nil {
61 | return err
62 | }
63 | }
64 | if _, ok := s["songs"]; ok {
65 | ret.Songs, err = strconv.Atoi(s["songs"])
66 | if err != nil {
67 | return err
68 | }
69 | }
70 | if _, ok := s["uptime"]; ok {
71 | ret.Uptime, err = strconv.Atoi(s["uptime"])
72 | if err != nil {
73 | return err
74 | }
75 | }
76 | if _, ok := s["db_playtime"]; ok {
77 | ret.LibraryPlaytime, err = strconv.Atoi(s["db_playtime"])
78 | if err != nil {
79 | return err
80 | }
81 | }
82 | if _, ok := s["db_update"]; ok {
83 | ret.LibraryUpdate, err = strconv.Atoi(s["db_update"])
84 | if err != nil {
85 | return err
86 | }
87 | }
88 | if _, ok := s["playtime"]; ok {
89 | ret.Playtime, err = strconv.Atoi(s["playtime"])
90 | if err != nil {
91 | return err
92 | }
93 | }
94 | // force update to Last-Modified header to calc current playing time
95 | return a.cache.Set(ret)
96 | }
97 |
98 | // ServeHTTP responses stats as json format.
99 | func (a *StatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
100 | a.cache.ServeHTTP(w, r)
101 | }
102 |
103 | // Changed returns stats update event chan.
104 | func (a *StatsHandler) Changed() <-chan struct{} {
105 | return a.cache.Changed()
106 | }
107 |
108 | // Close closes update event chan.
109 | func (a *StatsHandler) Close() {
110 | a.cache.Close()
111 | }
112 |
--------------------------------------------------------------------------------
/internal/vv/assets/handler.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import (
4 | "io"
5 | "net/http"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | "time"
10 |
11 | "github.com/meiraka/vv/internal/log"
12 | "github.com/meiraka/vv/internal/request"
13 | )
14 |
15 | // Config represents configuration
16 | type Config struct {
17 | Local bool // use local asset files
18 | LocalDir string // path to asset files directory
19 | LastModified time.Time // asset LastModified
20 | Logger interface {
21 | Debugf(format string, v ...interface{})
22 | }
23 | }
24 |
25 | type Handler struct {
26 | conf *Config
27 | lastModifed string
28 | }
29 |
30 | func NewHandler(conf *Config) (*Handler, error) {
31 | if conf == nil {
32 | conf = &Config{}
33 | }
34 | if conf.LocalDir == "" {
35 | conf.LocalDir = filepath.Join("internal", "vv", "assets")
36 | }
37 | if conf.LastModified.IsZero() {
38 | conf.LastModified = time.Now()
39 | }
40 | conf.LastModified = conf.LastModified.UTC()
41 | if conf.Logger == nil {
42 | conf.Logger = log.New(io.Discard)
43 | }
44 | h := &Handler{
45 | conf: conf,
46 | lastModifed: conf.LastModified.UTC().Format(http.TimeFormat),
47 | }
48 | return h, nil
49 | }
50 |
51 | // ServeHTTP serves asset files.
52 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
53 | if h.conf.Local {
54 | name := strings.TrimPrefix(r.URL.Path, "/assets/")
55 | rpath := filepath.Join(h.conf.LocalDir, filepath.FromSlash(name))
56 | s, err := os.Stat(rpath)
57 | if err == nil {
58 | if request.ModifiedSince(r, s.ModTime().UTC()) {
59 | w.Header().Add("Cache-Control", "max-age=1")
60 | http.ServeFile(w, r, rpath)
61 | } else {
62 | w.WriteHeader(http.StatusNotModified)
63 | }
64 | return
65 | }
66 | h.conf.Logger.Debugf("assets: %s: %v", r.URL.Path, err)
67 |
68 | }
69 | i, ok := embedIndex(r.URL.Path)
70 | if !ok {
71 | http.NotFound(w, r)
72 | return
73 | }
74 | gzBody := embedGZBody[i]
75 | useGZ := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && gzBody != nil
76 | var etag string
77 | if useGZ {
78 | etag = `"` + embedGZHash[i] + `"`
79 | } else {
80 | etag = `"` + embedHash[i] + `"`
81 | }
82 | if request.NoneMatch(r, etag) {
83 | w.WriteHeader(http.StatusNotModified)
84 | return
85 | }
86 | // extend the expiration date for versioned request
87 | if r.URL.Query().Get("h") != "" {
88 | w.Header().Add("Cache-Control", "max-age=31536000")
89 | } else {
90 | w.Header().Add("Cache-Control", "max-age=86400")
91 | }
92 | if m := embedMine[i]; m != "" {
93 | w.Header().Add("Content-Type", m)
94 | }
95 | w.Header().Add("ETag", etag)
96 | w.Header().Add("Last-Modified", h.lastModifed)
97 | if gzBody != nil {
98 | w.Header().Add("Vary", "Accept-Encoding")
99 | if useGZ {
100 | w.Header().Add("Content-Encoding", "gzip")
101 | w.Header().Add("Content-Length", embedGZLength[i])
102 | w.WriteHeader(http.StatusOK)
103 | w.Write(gzBody)
104 | return
105 | }
106 | }
107 | w.Header().Add("Content-Length", embedLength[i])
108 | w.Write(embedBody[i])
109 | }
110 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 | /vv
6 | internal/cmd/fix-assets/fix-assets
7 | build/
8 |
9 | # Folders
10 | _obj
11 | _test
12 |
13 | # Architecture specific extensions/prefixes
14 | *.[568vq]
15 | [568vq].out
16 |
17 | *.cgo1.go
18 | *.cgo2.c
19 | _cgo_defun.c
20 | _cgo_gotypes.go
21 | _cgo_export.*
22 |
23 | _testmain.go
24 |
25 | *.exe
26 | *.test
27 | *.prof
28 |
29 | # Logs
30 | logs
31 | *.log
32 | npm-debug.log*
33 | yarn-debug.log*
34 | yarn-error.log*
35 | lerna-debug.log*
36 | .pnpm-debug.log*
37 |
38 | # Diagnostic reports (https://nodejs.org/api/report.html)
39 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
40 |
41 | # Runtime data
42 | pids
43 | *.pid
44 | *.seed
45 | *.pid.lock
46 |
47 | # Directory for instrumented libs generated by jscoverage/JSCover
48 | lib-cov
49 |
50 | # Coverage directory used by tools like istanbul
51 | coverage
52 | *.lcov
53 |
54 | # nyc test coverage
55 | .nyc_output
56 |
57 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
58 | .grunt
59 |
60 | # Bower dependency directory (https://bower.io/)
61 | bower_components
62 |
63 | # node-waf configuration
64 | .lock-wscript
65 |
66 | # Compiled binary addons (https://nodejs.org/api/addons.html)
67 | build/Release
68 |
69 | # Dependency directories
70 | node_modules/
71 | jspm_packages/
72 |
73 | # Snowpack dependency directory (https://snowpack.dev/)
74 | web_modules/
75 |
76 | # TypeScript cache
77 | *.tsbuildinfo
78 |
79 | # Optional npm cache directory
80 | .npm
81 |
82 | # Optional eslint cache
83 | .eslintcache
84 |
85 | # Optional stylelint cache
86 | .stylelintcache
87 |
88 | # Microbundle cache
89 | .rpt2_cache/
90 | .rts2_cache_cjs/
91 | .rts2_cache_es/
92 | .rts2_cache_umd/
93 |
94 | # Optional REPL history
95 | .node_repl_history
96 |
97 | # Output of 'npm pack'
98 | *.tgz
99 |
100 | # Yarn Integrity file
101 | .yarn-integrity
102 |
103 | # dotenv environment variable files
104 | .env
105 | .env.development.local
106 | .env.test.local
107 | .env.production.local
108 | .env.local
109 |
110 | # parcel-bundler cache (https://parceljs.org/)
111 | .cache
112 | .parcel-cache
113 |
114 | # Next.js build output
115 | .next
116 | out
117 |
118 | # Nuxt.js build / generate output
119 | .nuxt
120 | dist
121 |
122 | # Gatsby files
123 | .cache/
124 | # Comment in the public line in if your project uses Gatsby and not Next.js
125 | # https://nextjs.org/blog/next-9-1#public-directory-support
126 | # public
127 |
128 | # vuepress build output
129 | .vuepress/dist
130 |
131 | # vuepress v2.x temp and cache directory
132 | .temp
133 | .cache
134 |
135 | # Docusaurus cache and generated files
136 | .docusaurus
137 |
138 | # Serverless directories
139 | .serverless/
140 |
141 | # FuseBox cache
142 | .fusebox/
143 |
144 | # DynamoDB Local files
145 | .dynamodb/
146 |
147 | # TernJS port file
148 | .tern-port
149 |
150 | # Stores VSCode versions used for testing VSCode extensions
151 | .vscode-test
152 |
153 | # yarn v2
154 | .yarn/cache
155 | .yarn/unplugged
156 | .yarn/build-state.yml
157 | .yarn/install-state.gz
158 | .pnp.*
159 |
--------------------------------------------------------------------------------
/internal/vv/api/images.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "net/http"
8 | "sync"
9 | "time"
10 | )
11 |
12 | type httpImages struct {
13 | Updating bool `json:"updating"`
14 | }
15 |
16 | type ImagesHandler struct {
17 | cache *cache
18 | imgBatch *imgBatch
19 | mu sync.RWMutex
20 | library []map[string][]string
21 | changed chan bool
22 | logger Logger
23 | }
24 |
25 | func NewImagesHandler(img []ImageProvider, logger Logger) (*ImagesHandler, error) {
26 | c, err := newCache(&httpImages{})
27 | if err != nil {
28 | return nil, err
29 | }
30 | ret := &ImagesHandler{
31 | cache: c,
32 | imgBatch: newImgBatch(img, logger),
33 | changed: make(chan bool, 10),
34 | logger: logger,
35 | }
36 | go func() {
37 | for e := range ret.imgBatch.Event() {
38 | ret.cache.SetIfModified(&httpImages{Updating: e})
39 | ret.changed <- e
40 | }
41 | close(ret.changed)
42 | }()
43 | return ret, nil
44 | }
45 |
46 | func (a *ImagesHandler) ConvSong(s map[string][]string) (map[string][]string, bool) {
47 | delete(s, "cover")
48 | cover, updated := a.imgBatch.GetURLs(s)
49 | if len(cover) != 0 {
50 | s["cover"] = cover
51 | }
52 | return s, updated
53 | }
54 |
55 | func (a *ImagesHandler) ConvSongs(s []map[string][]string) []map[string][]string {
56 | ret := make([]map[string][]string, len(s))
57 | needUpdates := make([]map[string][]string, 0, len(s))
58 | for i := range s {
59 | song, ok := a.ConvSong(s[i])
60 | ret[i] = song
61 | if !ok {
62 | needUpdates = append(needUpdates, song)
63 | }
64 | }
65 | if len(needUpdates) != 0 {
66 | if err := a.imgBatch.Update(needUpdates); err != nil {
67 | a.logger.Debugf("vv/api: images: %v", err)
68 | }
69 | }
70 | return ret
71 | }
72 |
73 | func (a *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
74 | if r.Method != "POST" {
75 | a.cache.ServeHTTP(w, r)
76 | return
77 | }
78 | var req httpImages
79 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
80 | writeHTTPError(w, http.StatusBadRequest, err)
81 | return
82 | }
83 | if !req.Updating {
84 | writeHTTPError(w, http.StatusBadRequest, errors.New("requires updating=true"))
85 | return
86 | }
87 | a.mu.RLock()
88 | library := a.library
89 | a.mu.RUnlock()
90 | if err := a.imgBatch.Rescan(library); err != nil {
91 | writeHTTPError(w, http.StatusInternalServerError, err)
92 | return
93 | }
94 | now := time.Now().UTC()
95 | r.Method = http.MethodGet
96 | a.cache.ServeHTTP(w, setUpdateTime(r, now))
97 | }
98 |
99 | // UpdateLibrarySongs set songs for rescan images.
100 | func (a *ImagesHandler) UpdateLibrarySongs(songs []map[string][]string) {
101 | a.mu.Lock()
102 | a.library = songs
103 | a.mu.Unlock()
104 | }
105 |
106 | // Changed returns response body changes event chan.
107 | func (a *ImagesHandler) Changed() <-chan bool {
108 | return a.changed
109 | }
110 |
111 | // Close closes update event chan.
112 | func (a *ImagesHandler) Close() {
113 | a.cache.Close()
114 | }
115 |
116 | // Shutdown stops background image updater api.
117 | func (a *ImagesHandler) Shutdown(ctx context.Context) error {
118 | return a.imgBatch.Shutdown(ctx)
119 | }
120 |
--------------------------------------------------------------------------------
/internal/mpd/mpdtest/server.go:
--------------------------------------------------------------------------------
1 | package mpdtest
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "net"
8 | "strings"
9 | "sync"
10 | )
11 |
12 | // Server is mock mpd server.
13 | type Server struct {
14 | ln net.Listener
15 | Proto string
16 | URL string
17 | disconnect chan struct{}
18 | rc chan *rConn
19 | mu sync.Mutex
20 | closed bool
21 | }
22 |
23 | type rConn struct {
24 | read string
25 | wc chan string
26 | }
27 |
28 | // Disconnect closes current connection.
29 | func (s *Server) Disconnect(ctx context.Context) {
30 | s.mu.Lock()
31 | if s.closed {
32 | s.mu.Unlock()
33 | return
34 | }
35 | select {
36 | case s.disconnect <- struct{}{}:
37 | case <-ctx.Done():
38 | }
39 | s.mu.Unlock()
40 | }
41 |
42 | // Close closes connection
43 | func (s *Server) Close() {
44 | s.mu.Lock()
45 | defer s.mu.Unlock()
46 | if !s.closed {
47 | s.closed = true
48 | close(s.disconnect)
49 | s.ln.Close()
50 | }
51 | }
52 |
53 | // WR represents testserver Write / Read string
54 | type WR struct {
55 | Read string
56 | Write string
57 | }
58 |
59 | // Expect expects mpd read/write message
60 | func (s *Server) Expect(ctx context.Context, m *WR) error {
61 | select {
62 | case <-ctx.Done():
63 | return ctx.Err()
64 | case r := <-s.rc:
65 | w := m.Write
66 | if r.read != m.Read {
67 | got, want := strings.TrimSuffix(r.read, "\n"), strings.TrimSuffix(m.Read, "\n")
68 | w = fmt.Sprintf("ACK [5@0] {%s} got %q; want %q\n", got, got, want)
69 | }
70 | select {
71 | case <-ctx.Done():
72 | case r.wc <- w:
73 | }
74 | }
75 | return nil
76 | }
77 |
78 | // NewServer creates new mpd mock Server for idle command.
79 | func NewServer(firstResp string) *Server {
80 | ln, err := net.Listen("tcp", "127.0.0.1:0")
81 | if err != nil {
82 | if ln, err = net.Listen("tcp6", "[::1]:0"); err != nil {
83 | panic(fmt.Sprintf("mpdtest: failed to listen on a port: %v", err))
84 | }
85 | }
86 | rc := make(chan *rConn)
87 | s := &Server{
88 | ln: ln,
89 | Proto: "tcp",
90 | URL: ln.Addr().String(),
91 | disconnect: make(chan struct{}, 1),
92 | rc: rc,
93 | }
94 | go func(ln net.Listener) {
95 | for {
96 | conn, err := ln.Accept()
97 | if err != nil {
98 | return
99 | }
100 | go func(conn net.Conn) {
101 | ctx, cancel := context.WithCancel(context.Background())
102 | defer cancel()
103 | go func() {
104 | defer cancel()
105 | defer conn.Close()
106 | if _, err := fmt.Fprintln(conn, firstResp); err != nil {
107 | return
108 | }
109 | r := bufio.NewReader(conn)
110 | wc := make(chan string, 1)
111 | for {
112 | nl, err := r.ReadString('\n')
113 | if err != nil {
114 | return
115 | }
116 | rc <- &rConn{
117 | read: nl,
118 | wc: wc,
119 | }
120 | select {
121 | case <-ctx.Done():
122 | return
123 | case l := <-wc:
124 | if len(l) != 0 {
125 | if _, err := fmt.Fprint(conn, l); err != nil {
126 | return
127 | }
128 | }
129 | }
130 | }
131 | }()
132 | select {
133 | case <-ctx.Done():
134 | case <-s.disconnect:
135 | }
136 | conn.Close()
137 | }(conn)
138 | }
139 | }(ln)
140 | return s
141 | }
142 |
--------------------------------------------------------------------------------
/internal/vv/api/cache_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "net/http/httptest"
9 | "testing"
10 | "time"
11 | )
12 |
13 | func TestCacheSet(t *testing.T) {
14 | var oldDate time.Time
15 | b, err := newCache(nil)
16 | if err != nil {
17 | t.Fatalf("failed to init cache: %v", err)
18 | }
19 |
20 | if b, _, date := b.get(); string(b) != `null` || date.Equal(time.Time{}) {
21 | t.Errorf("got %s, _, %v; want nil, _, not time.Time{}", b, date)
22 | }
23 | b.Set(map[string]int{"a": 1})
24 | if b, _, date := b.get(); string(b) != `{"a":1}` || date.Equal(time.Time{}) {
25 | t.Errorf("got %s, _, %v; want %s, _, not time.Time{}", b, date, `{"a":1}`)
26 | } else {
27 | oldDate = date
28 | }
29 | b.SetIfModified(map[string]int{"a": 1})
30 | if b, _, date := b.get(); string(b) != `{"a":1}` || !date.Equal(oldDate) {
31 | t.Errorf("got %s, _, %v; want %s, _, %v", b, date, `{"a":1}`, oldDate)
32 | } else {
33 | oldDate = date
34 | }
35 | b.Set(map[string]int{"a": 1})
36 | if b, _, date := b.get(); string(b) != `{"a":1}` || date.Equal(oldDate) {
37 | t.Errorf("got %s, _, %v; want %s, _, not %v", b, date, `{"a":1}`, oldDate)
38 | } else {
39 | oldDate = date
40 | }
41 | b.SetIfModified(map[string]int{"a": 2})
42 | if b, _, date := b.get(); string(b) != `{"a":2}` || date.Equal(oldDate) {
43 | t.Errorf("got %s, _, %v; want %s, _, not %v", b, date, `{"a":2}`, oldDate)
44 | }
45 |
46 | }
47 |
48 | func TestCacheHandler(t *testing.T) {
49 | b, err := newCache(nil)
50 | if err != nil {
51 | t.Fatalf("failed to init cache: %v", err)
52 | }
53 | b.SetIfModified(map[string]int{"a": 1})
54 | ts := httptest.NewServer(b)
55 | defer ts.Close()
56 | body, gz, date := b.get()
57 | testsets := []struct {
58 | header http.Header
59 | status int
60 | want []byte
61 | }{
62 | {header: http.Header{"Accept-Encoding": {"identity"}}, status: 200, want: body},
63 | {header: http.Header{"Accept-Encoding": {"gzip"}}, status: 200, want: gz},
64 | {header: http.Header{"Accept-Encoding": {"identity"}, "If-None-Match": {fmt.Sprintf(`"%d.%d"`, date.Unix(), date.Nanosecond())}}, status: 304, want: []byte{}},
65 | {header: http.Header{"Accept-Encoding": {"identity"}, "If-None-Match": {""}}, status: 200, want: body},
66 | {header: http.Header{"Accept-Encoding": {"identity"}, "If-Modified-Since": {date.Format(http.TimeFormat)}}, status: 304, want: []byte{}},
67 | {header: http.Header{"Accept-Encoding": {"identity"}, "If-Modified-Since": {date.Add(time.Second).Format(http.TimeFormat)}}, status: 304, want: []byte{}},
68 | {header: http.Header{"Accept-Encoding": {"identity"}, "If-Modified-Since": {date.Add(-1 * time.Second).Format(http.TimeFormat)}}, status: 200, want: body},
69 | }
70 | for _, tt := range testsets {
71 | t.Run(fmt.Sprint(tt.header), func(t *testing.T) {
72 | req, _ := http.NewRequest(http.MethodGet, ts.URL, nil)
73 | for k, v := range tt.header {
74 | for i := range v {
75 | req.Header.Add(k, v[i])
76 | }
77 | }
78 | resp, err := testHTTPClient.Do(req)
79 | if err != nil {
80 | t.Fatalf("failed to request: %v", err)
81 | }
82 | defer resp.Body.Close()
83 | got, err := io.ReadAll(resp.Body)
84 | if err != nil {
85 | t.Errorf("got resp.Body error: %v", err)
86 | }
87 | if !bytes.Equal(got, tt.want) || resp.StatusCode != tt.status {
88 | t.Errorf("got %d %s; want %d %s", resp.StatusCode, got, tt.status, tt.want)
89 | }
90 |
91 | })
92 |
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/internal/mpd/pool.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "context"
5 | "sync"
6 | "time"
7 | )
8 |
9 | type pool struct {
10 | proto string
11 | addr string
12 | Timeout time.Duration
13 | ReconnectionInterval time.Duration
14 | connHook func(*conn) error
15 | connC chan *conn
16 | connCtx context.Context
17 | connCancel context.CancelFunc
18 | mu sync.RWMutex
19 | version string
20 | }
21 |
22 | func newPool(proto string, addr string, timeout time.Duration, reconnectionInterval time.Duration, connHook func(*conn) error) (*pool, error) {
23 | ctx, cancel := context.WithCancel(context.Background())
24 | p := &pool{
25 | proto: proto,
26 | addr: addr,
27 | Timeout: timeout,
28 | ReconnectionInterval: reconnectionInterval,
29 | connHook: connHook,
30 | connC: make(chan *conn, 1),
31 | connCtx: ctx,
32 | connCancel: cancel,
33 | }
34 | if err := p.connectOnce(); err != nil {
35 | return nil, err
36 | }
37 | return p, nil
38 | }
39 |
40 | func (c *pool) Exec(ctx context.Context, f func(*conn) error) error {
41 | conn, err := c.get(ctx)
42 | if err != nil {
43 | return err
44 | }
45 | errs := make(chan error)
46 | go func() {
47 | errs <- f(conn)
48 | close(errs)
49 | }()
50 | select {
51 | case err = <-errs:
52 | case <-ctx.Done():
53 | err = ctx.Err()
54 | conn.SetDeadline(time.Now())
55 | }
56 | return c.returnConn(conn, err)
57 | }
58 |
59 | func (c *pool) Close(ctx context.Context) error {
60 | c.connCancel()
61 | conn, err := c.get(ctx)
62 | if err != nil {
63 | return err
64 | }
65 | close(c.connC)
66 | return conn.Close()
67 | }
68 |
69 | func (c *pool) Version() string {
70 | c.mu.RLock()
71 | defer c.mu.RUnlock()
72 | return c.version
73 | }
74 |
75 | func (c *pool) get(ctx context.Context) (*conn, error) {
76 | select {
77 | case conn, ok := <-c.connC:
78 | if !ok {
79 | return nil, ErrClosed
80 | }
81 | if d, ok := ctx.Deadline(); ok {
82 | conn.SetDeadline(d)
83 | } else {
84 | conn.SetDeadline(time.Time{})
85 | }
86 | return conn, nil
87 | case <-ctx.Done():
88 | return nil, ctx.Err()
89 | }
90 | }
91 |
92 | func (c *pool) returnConn(conn *conn, err error) error {
93 | if err != nil {
94 | if _, ok := err.(*CommandError); !ok {
95 | conn.Close()
96 | go c.connect()
97 | return err
98 | }
99 | }
100 | c.connC <- conn
101 | return err
102 | }
103 |
104 | func (c *pool) connect() {
105 | for {
106 | if err := c.connectOnce(); err != nil {
107 | select {
108 | case <-c.connCtx.Done():
109 | close(c.connC)
110 | return
111 | case <-time.After(c.ReconnectionInterval):
112 | }
113 | continue
114 | }
115 | return
116 | }
117 | }
118 |
119 | func (c *pool) connectOnce() error {
120 | ctx := c.connCtx
121 | if c.Timeout > 0 {
122 | var cancel context.CancelFunc
123 | ctx, cancel = context.WithTimeout(ctx, c.Timeout)
124 | defer cancel()
125 | }
126 | conn, err := newConn(ctx, c.proto, c.addr)
127 | if err != nil {
128 | return err
129 | }
130 | if err := c.connHook(conn); err != nil {
131 | conn.Close()
132 | return err
133 | }
134 | c.connC <- conn
135 | c.mu.Lock()
136 | defer c.mu.Unlock()
137 | c.version = conn.Version
138 | return nil
139 | }
140 |
--------------------------------------------------------------------------------
/internal/vv/api/images/local.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "net/url"
7 | "os"
8 | "path"
9 | "path/filepath"
10 | "strconv"
11 | "strings"
12 | "sync"
13 | )
14 |
15 | // Local provides http server song conver art from local filesystem.
16 | type Local struct {
17 | httpPrefix string
18 | musicDirectory string
19 | files []string
20 | cache map[string][]string
21 | url2img map[string]string
22 | img2req map[string]string
23 | mu sync.RWMutex
24 | }
25 |
26 | // NewLocal creates Local.
27 | func NewLocal(httpPrefix string, dir string, files []string) (*Local, error) {
28 | dir, err := filepath.Abs(dir)
29 | if err != nil {
30 | return nil, err
31 | }
32 | return &Local{
33 | httpPrefix: httpPrefix,
34 | musicDirectory: dir,
35 | files: files,
36 | cache: map[string][]string{},
37 | url2img: map[string]string{},
38 | img2req: map[string]string{},
39 | }, nil
40 | }
41 |
42 | // ServeHTTP serves cover art with httpPrefix
43 | func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
44 | l.mu.RLock()
45 | path, ok := l.url2img[r.URL.Path]
46 | l.mu.RUnlock()
47 | if !ok {
48 | http.NotFound(w, r)
49 | return
50 | }
51 | serveImage(path, w, r)
52 | }
53 |
54 | // Update rescans all songs images.
55 | func (l *Local) Update(ctx context.Context, song map[string][]string) error {
56 | k, ok := l.songDirPath(song)
57 | if !ok {
58 | return nil
59 | }
60 |
61 | l.mu.RLock()
62 | _, ok = l.cache[k]
63 | l.mu.RUnlock()
64 | if ok {
65 | return nil
66 | }
67 |
68 | l.updateCache(k)
69 | return nil
70 | }
71 |
72 | // Rescan rescans song image.
73 | func (l *Local) Rescan(ctx context.Context, song map[string][]string, reqid string) error {
74 | k, ok := l.songDirPath(song)
75 | if !ok {
76 | return nil
77 | }
78 |
79 | l.mu.Lock()
80 | lastReq, ok := l.img2req[k]
81 | l.img2req[k] = reqid
82 | l.mu.Unlock()
83 | if ok && lastReq == reqid {
84 | return nil
85 | }
86 | l.updateCache(k)
87 | return nil
88 | }
89 |
90 | func (l *Local) songDirPath(song map[string][]string) (string, bool) {
91 | file, ok := song["file"]
92 | if !ok {
93 | return "", false
94 | }
95 | if len(file) != 1 {
96 | return "", false
97 | }
98 | localPath := filepath.Join(filepath.FromSlash(l.musicDirectory), filepath.FromSlash(file[0]))
99 | return filepath.Dir(localPath), true
100 | }
101 |
102 | func (l *Local) updateCache(songDirPath string) []string {
103 | l.mu.Lock()
104 | defer l.mu.Unlock()
105 | ret := []string{}
106 | for _, n := range l.files {
107 | rpath := filepath.Join(songDirPath, n)
108 | s, err := os.Stat(rpath)
109 | if err == nil {
110 | cover := path.Join(l.httpPrefix, strings.TrimPrefix(strings.TrimPrefix(filepath.ToSlash(rpath), filepath.ToSlash(l.musicDirectory)), "/"))
111 |
112 | ret = append(ret, cover+"?"+url.Values{"d": {strconv.FormatInt(s.ModTime().Unix(), 10)}}.Encode())
113 | l.url2img[cover] = rpath
114 | }
115 | }
116 | l.cache[songDirPath] = ret
117 | return ret
118 | }
119 |
120 | // GetURLs returns cover path for m
121 | func (l *Local) GetURLs(m map[string][]string) ([]string, bool) {
122 | if l == nil {
123 | return nil, true
124 | }
125 | songDirPath, ok := l.songDirPath(m)
126 | if !ok {
127 | return nil, true
128 | }
129 |
130 | l.mu.RLock()
131 | v, ok := l.cache[songDirPath]
132 | l.mu.RUnlock()
133 | if ok {
134 | return v, true
135 | }
136 | return l.updateCache(songDirPath), true
137 | }
138 |
--------------------------------------------------------------------------------
/internal/songs/tags_test.go:
--------------------------------------------------------------------------------
1 | package songs
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestAddTags(t *testing.T) {
9 | for _, tt := range []struct {
10 | in map[string][]string
11 | want map[string][]string
12 | }{
13 | {
14 | in: map[string][]string{"file": {"hoge"}},
15 | want: map[string][]string{"file": {"hoge"}, "TrackNumber": {"0000"}, "DiscNumber": {"0001"}, "Length": {"00:00"}},
16 | },
17 | {
18 | in: map[string][]string{"file": {"appendix/hoge"}, "Track": {"1"}, "Disc": {"2"}, "Time": {"121"}, "Last-Modified": {"2008-09-28T20:04:57Z"}},
19 | want: map[string][]string{"file": {"appendix/hoge"}, "Track": {"1"}, "Disc": {"2"}, "Time": {"121"}, "Last-Modified": {"2008-09-28T20:04:57Z"}, "TrackNumber": {"0001"}, "DiscNumber": {"0002"}, "Length": {"02:01"}, "LastModifiedDate": {"2008.09.28"}},
20 | },
21 | } {
22 | if got := AddTags(tt.in); !reflect.DeepEqual(got, tt.want) {
23 | t.Errorf("got AddTags(%v) = %v; want %v", tt.in, got, tt.want)
24 | }
25 |
26 | }
27 | }
28 |
29 | func TestSongTags(t *testing.T) {
30 | testsets := []struct {
31 | song map[string][]string
32 | input string
33 | want []string
34 | }{
35 | {song: map[string][]string{"Artist": {"baz"}, "Album": {"foobar"}}, input: "Date", want: nil},
36 | {song: map[string][]string{"Artist": {"baz"}, "Album": {"foobar"}}, input: "Date-Artist", want: []string{"baz"}},
37 | {song: map[string][]string{"Artist": {"baz"}, "Album": {"foobar"}}, input: "Artist-Date", want: []string{"baz"}},
38 | {song: map[string][]string{"Artist": {"baz"}, "Album": {"foobar"}}, input: "Artist-Date-Album", want: []string{"baz-foobar"}},
39 | {song: map[string][]string{"Artist": {"baz", "qux"}, "Album": {"foo", "bar"}}, input: "Artist-Album", want: []string{"baz-foo", "baz-bar", "qux-foo", "qux-bar"}},
40 | }
41 | for _, tt := range testsets {
42 | if got := Tags(tt.song, tt.input); !reflect.DeepEqual(got, tt.want) {
43 | t.Errorf("got Tags(%v, %v) = %v; want %v", tt.song, tt.input, got, tt.want)
44 | }
45 | }
46 | }
47 |
48 | func TestSongTag(t *testing.T) {
49 | testsets := []struct {
50 | song map[string][]string
51 | input string
52 | want []string
53 | }{
54 | {song: map[string][]string{"Album": {"foobar"}}, input: "Artist", want: nil},
55 | {song: map[string][]string{"Album": {"foobar"}}, input: "ArtistSort", want: nil},
56 | {song: map[string][]string{"Album": {"foobar"}}, input: "AlbumArtist", want: nil},
57 | {song: map[string][]string{"Album": {"foobar"}}, input: "AlbumArtistSort", want: nil},
58 | {song: map[string][]string{"Artist": {"foobar"}}, input: "AlbumArtistSort", want: []string{"foobar"}},
59 | {song: map[string][]string{"Album": {"foobar"}}, input: "AlbumSort", want: []string{"foobar"}},
60 | {song: map[string][]string{"Album": {"foobar"}}, input: "Album", want: []string{"foobar"}},
61 | {song: map[string][]string{"Album": {"foobar"}}, input: "Date", want: nil},
62 | {song: map[string][]string{"Date": {"foobar"}}, input: "Date", want: []string{"foobar"}},
63 | {song: map[string][]string{"OriginalDate": {"foobar"}}, input: "Date", want: []string{"foobar"}},
64 | {song: map[string][]string{"Album": {"foobar"}}, input: "OriginalDate", want: nil},
65 | {song: map[string][]string{"Date": {"foobar"}}, input: "OriginalDate", want: []string{"foobar"}},
66 | {song: map[string][]string{"OriginalDate": {"foobar"}}, input: "OriginalDate", want: []string{"foobar"}},
67 | }
68 | for _, tt := range testsets {
69 | if got := Tag(tt.song, tt.input); !reflect.DeepEqual(got, tt.want) {
70 | t.Errorf("got Tag(%v, %v) = %v; want %v", tt.song, tt.input, got, tt.want)
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/internal/mpd/watcher.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "strings"
7 | "time"
8 | )
9 |
10 | // NewWatcher connects to mpd server
11 | func NewWatcher(proto, addr string, opts *WatcherOptions) (*Watcher, error) {
12 | if opts == nil {
13 | opts = &WatcherOptions{}
14 | }
15 | args := make([]interface{}, len(opts.SubSystems))
16 | for i := range opts.SubSystems {
17 | args[i] = opts.SubSystems[i]
18 | }
19 | pool, err := newPool(proto, addr, opts.Timeout, opts.ReconnectionInterval, opts.connectHook)
20 | if err != nil {
21 | return nil, err
22 | }
23 | event := make(chan string, 10)
24 | ctx, cancel := context.WithCancel(context.Background())
25 | closed := make(chan struct{})
26 | w := &Watcher{
27 | event: event,
28 | closed: closed,
29 | pool: pool,
30 | cancel: cancel,
31 | }
32 | go func() {
33 | defer close(closed)
34 | defer close(event)
35 | var err error
36 | for {
37 | select {
38 | case <-ctx.Done():
39 | return
40 | default:
41 | }
42 | // TODO: logging
43 | if err != nil {
44 | select {
45 | case event <- "reconnecting":
46 | default:
47 | }
48 | }
49 | err = w.pool.Exec(context.Background() /* do not use ctx to graceful shutdown */, func(conn *conn) error {
50 | if err != nil {
51 | select {
52 | case event <- "reconnect":
53 | default:
54 | }
55 | }
56 | if err := request(conn, "idle", args...); err != nil {
57 | return err
58 | }
59 | readCtx, writeCancel := context.WithCancel(context.Background())
60 | defer writeCancel()
61 | go func() {
62 | select {
63 | case <-ctx.Done():
64 | // TODO: logging
65 | select {
66 | case <-readCtx.Done():
67 | return
68 | default:
69 | }
70 | _, _ = fmt.Fprintln(conn, "noidle")
71 | return
72 | case <-readCtx.Done():
73 | return
74 | }
75 |
76 | }()
77 | for {
78 | line, err := readln(conn)
79 | writeCancel()
80 | if err != nil {
81 | return err
82 | }
83 | if strings.HasPrefix(line, "changed: ") {
84 | select {
85 | case event <- strings.TrimPrefix(line, "changed: "):
86 | default:
87 | }
88 | } else if line != "OK" {
89 | return parseCommandError(line[0 : len(line)-1])
90 | } else {
91 | return nil
92 | }
93 | }
94 | })
95 | }
96 |
97 | }()
98 | return w, nil
99 | }
100 |
101 | // Watcher is the mpd idle command watcher.
102 | type Watcher struct {
103 | pool *pool
104 | closed <-chan struct{}
105 | event chan string
106 | cancel func()
107 | }
108 |
109 | // Event returns event channel which sends idle command outputs.
110 | func (w *Watcher) Event() <-chan string {
111 | return w.event
112 | }
113 |
114 | // Close closes connection
115 | func (w *Watcher) Close(ctx context.Context) error {
116 | w.cancel()
117 | err := w.pool.Close(ctx)
118 | select {
119 | case <-w.closed:
120 | case <-ctx.Done():
121 | return ctx.Err()
122 | }
123 | return err
124 | }
125 |
126 | // WatcherOptions contains options for mpd idle command connection.
127 | type WatcherOptions struct {
128 | Password string
129 | Timeout time.Duration
130 | ReconnectionInterval time.Duration
131 | // SubSystems are list of recieve events. Watcher recieves all events if SubSystems are empty.
132 | SubSystems []string
133 | }
134 |
135 | func (c *WatcherOptions) connectHook(conn *conn) error {
136 | if len(c.Password) > 0 {
137 | if err := execOK(conn, "password", c.Password); err != nil {
138 | return err
139 | }
140 | }
141 | return nil
142 | }
143 |
--------------------------------------------------------------------------------
/internal/vv/api/stats_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "net/http/httptest"
8 | "testing"
9 |
10 | "github.com/meiraka/vv/internal/vv/api"
11 | )
12 |
13 | func TestStatsHandlerGET(t *testing.T) {
14 | for label, tt := range map[string][]struct {
15 | label string
16 | stats func() (map[string]string, error)
17 | err error
18 | want string
19 | changed bool
20 | }{
21 | "ok": {{
22 | label: "empty",
23 | stats: func() (map[string]string, error) {
24 | return map[string]string{}, nil
25 | },
26 | want: `{"uptime":0,"playtime":0,"artists":0,"albums":0,"songs":0,"library_playtime":0,"library_update":0}`,
27 | changed: true,
28 | }, {
29 | label: "all",
30 | stats: func() (map[string]string, error) {
31 | return map[string]string{
32 | "artists": "6",
33 | "albums": "5",
34 | "songs": "4",
35 | "uptime": "3",
36 | "db_playtime": "2",
37 | "db_update": "1",
38 | "playtime": "10",
39 | }, nil
40 | },
41 | want: `{"uptime":3,"playtime":10,"artists":6,"albums":5,"songs":4,"library_playtime":2,"library_update":1}`,
42 | changed: true,
43 | }, {
44 | label: "remove",
45 | stats: func() (map[string]string, error) {
46 | return map[string]string{}, nil
47 | },
48 | want: `{"uptime":0,"playtime":0,"artists":0,"albums":0,"songs":0,"library_playtime":0,"library_update":0}`,
49 | changed: true,
50 | }},
51 | "error": {{
52 | label: "prepare data",
53 | stats: func() (map[string]string, error) {
54 | return map[string]string{"uptime": "100"}, nil
55 | },
56 | want: `{"uptime":100,"playtime":0,"artists":0,"albums":0,"songs":0,"library_playtime":0,"library_update":0}`,
57 | changed: true,
58 | }, {
59 | label: "error",
60 | stats: func() (map[string]string, error) {
61 | return nil, errTest
62 | },
63 | err: errTest,
64 | want: `{"uptime":100,"playtime":0,"artists":0,"albums":0,"songs":0,"library_playtime":0,"library_update":0}`,
65 | changed: false,
66 | }},
67 | } {
68 | t.Run(label, func(t *testing.T) {
69 | mpd := &mpdStats{t: t}
70 | h, err := api.NewStatsHandler(mpd)
71 | if err != nil {
72 | t.Fatalf("api.NewStatsHandler(mpd) = %v", err)
73 | }
74 | defer h.Close()
75 | for i := range tt {
76 | f := func(t *testing.T) {
77 | mpd.t = t
78 | mpd.stats = tt[i].stats
79 | if err := h.Update(context.TODO()); !errors.Is(err, tt[i].err) {
80 | t.Errorf("handler.Update(context.TODO()) = %v; want %v", err, tt[i].err)
81 | }
82 | r := httptest.NewRequest(http.MethodGet, "/", nil)
83 | w := httptest.NewRecorder()
84 | h.ServeHTTP(w, r)
85 | if status, got := w.Result().StatusCode, w.Body.String(); status != http.StatusOK || got != tt[i].want {
86 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, http.StatusOK, tt[i].want)
87 | }
88 | if changed := recieveMsg(h.Changed()); changed != tt[i].changed {
89 | t.Errorf("changed = %v; want %v", changed, tt[i].changed)
90 | }
91 | }
92 | if len(tt) != 1 {
93 | if tt[i].label == "" {
94 | t.Fatalf("test definition error: no test label")
95 | }
96 | t.Run(tt[i].label, f)
97 | } else {
98 | f(t)
99 | }
100 | }
101 | })
102 | }
103 | }
104 |
105 | type mpdStats struct {
106 | t *testing.T
107 | stats func() (map[string]string, error)
108 | }
109 |
110 | func (m *mpdStats) Stats(context.Context) (map[string]string, error) {
111 | m.t.Helper()
112 | if m.stats == nil {
113 | m.t.Fatal("no Stats mock function")
114 | }
115 | return m.stats()
116 | }
117 |
--------------------------------------------------------------------------------
/internal/songs/sort_test.go:
--------------------------------------------------------------------------------
1 | package songs
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestWeakFilterSort(t *testing.T) {
9 | a := map[string][]string{"Artist": {"foo", "bar"}, "Track": {"1"}, "Album": {"baz"}, "Genre": {"qux"}}
10 | b := map[string][]string{"Artist": {"bar"}, "Track": {"2"}, "Album": {"baz"}}
11 | c := map[string][]string{"Artist": {"hoge", "fuga"}, "Album": {"piyo"}}
12 | songs := []map[string][]string{a, b, c}
13 | testsets := map[string]struct {
14 | desc string
15 | keys []string
16 | filters [][2]*string
17 | must int
18 | max int
19 | pos int
20 | want []map[string][]string
21 | wantPos int
22 | }{
23 | "": {
24 | keys: []string{"Album", "Track"},
25 | max: 100, filters: [][2]*string{},
26 | pos: 0,
27 | want: []map[string][]string{a, b, c},
28 | wantPos: 0,
29 | },
30 | "invalid pos returns -1": {
31 | keys: []string{"Album", "Track"},
32 | max: 100, filters: [][2]*string{},
33 | pos: -1,
34 | want: []map[string][]string{a, b, c},
35 | wantPos: -1,
36 | },
37 | "filter 1st item": {
38 | keys: []string{"Album", "Track"},
39 | max: 2, filters: [][2]*string{{strPtr("Album"), strPtr("baz")}, {strPtr("Track"), strPtr("1")}},
40 | pos: 0,
41 | want: []map[string][]string{a, b},
42 | wantPos: 0,
43 | },
44 | "filter 1st item(must)": {
45 | keys: []string{"Album", "Track"},
46 | max: 100, filters: [][2]*string{{strPtr("Album"), strPtr("piyo")}, {strPtr("Track"), nil}},
47 | must: 1,
48 | pos: 2,
49 | want: []map[string][]string{c},
50 | wantPos: 0,
51 | },
52 | "filter 2nd item": {
53 | keys: []string{"Album", "Track"},
54 | max: 1, filters: [][2]*string{{strPtr("Album"), strPtr("baz")}, {strPtr("Track"), strPtr("1")}},
55 | pos: 0,
56 | want: []map[string][]string{a},
57 | wantPos: 0,
58 | },
59 | "filter 2nd item(must)": {
60 | keys: []string{"Album", "Track"},
61 | max: 100, filters: [][2]*string{{strPtr("Album"), strPtr("baz")}, {strPtr("Track"), strPtr("1")}},
62 | must: 2,
63 | pos: 0,
64 | want: []map[string][]string{a},
65 | wantPos: 0,
66 | },
67 | "filter by max value": {
68 | keys: []string{"Album", "Track"},
69 | max: 1, filters: [][2]*string{{strPtr("Album"), strPtr("baz")}},
70 | pos: 0,
71 | want: []map[string][]string{a},
72 | wantPos: 0,
73 | },
74 | "multi tags": {
75 | keys: []string{"Artist", "Album"},
76 | max: 100, filters: [][2]*string{{strPtr("Artist"), strPtr("fuga")}},
77 | pos: 3,
78 | want: []map[string][]string{a, b, a, c, c},
79 | wantPos: 3,
80 | },
81 | "wantPos changed {removed(a), removed(b), removed(a), selected(c), removed(c)}": {
82 | keys: []string{"Artist", "Album"},
83 | max: 1, filters: [][2]*string{{strPtr("Artist"), strPtr("fuga")}},
84 | pos: 3,
85 | want: []map[string][]string{c},
86 | wantPos: 0,
87 | },
88 | "selected pos was removed {selected(removed(a)), removed(b), removed(a), c, removed(c)}": {
89 | keys: []string{"Artist", "Album"},
90 | max: 1, filters: [][2]*string{{strPtr("Artist"), strPtr("fuga")}},
91 | pos: 0,
92 | want: []map[string][]string{c},
93 | wantPos: -1,
94 | },
95 | }
96 | for label, tt := range testsets {
97 | t.Run(label, func(t *testing.T) {
98 | got, _, pos := WeakFilterSort(songs, tt.keys, tt.filters, tt.must, tt.max, tt.pos)
99 | if !reflect.DeepEqual(got, tt.want) || pos != tt.wantPos {
100 | t.Errorf("got WeakFilterSort(%v, %v, %v, %v, %v) =\n%v, _, %v; want\n%v, _, %v", songs, tt.keys, tt.filters, tt.max, tt.pos, got, pos, tt.want, tt.wantPos)
101 | }
102 | })
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/internal/mpd/config.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "bufio"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "os"
9 | )
10 |
11 | // Config represents MPD config struct
12 | type Config struct {
13 | MusicDirectory string
14 | AudioOutputs []*ConfigAudioOutput
15 | }
16 |
17 | // ConfigAudioOutput represents MPD audio_output struct.
18 | type ConfigAudioOutput struct {
19 | Type string
20 | Name string
21 | Port string
22 | }
23 |
24 | // ParseConfig parses mpd.conf
25 | func ParseConfig(file string) (*Config, error) {
26 | f, err := os.Open(file)
27 | if err != nil {
28 | return nil, err
29 | }
30 | defer f.Close()
31 | ret := &Config{}
32 | if err := parseConfig(f, ret); err != nil {
33 | return nil, err
34 | }
35 | return ret, nil
36 | }
37 |
38 | type state int
39 |
40 | const (
41 | parseKey state = iota
42 | parseKeyEnd
43 | parseValue
44 | parseBraceKey
45 | parseBraceKeyEnd
46 | parseBraceValue
47 | )
48 |
49 | func parseConfig(r io.Reader, c *Config) error {
50 | sc := bufio.NewScanner(r)
51 |
52 | brace := map[string]string{}
53 |
54 | key := []rune{}
55 | braceKey := []rune{}
56 | value := []rune{}
57 | var current state
58 | for i := 1; sc.Scan(); i++ {
59 | if err := sc.Err(); err != nil {
60 | return err
61 | }
62 | if current == parseKey && len(key) == 0 {
63 | } else if current == parseBraceKey && len(braceKey) == 0 {
64 | } else {
65 | return fmt.Errorf("parse error unknown state: %d", current)
66 | }
67 |
68 | l := sc.Text()
69 | parseLine:
70 | for _, t := range l {
71 | if t == ' ' || t == '\t' {
72 | if current == parseKey && len(key) != 0 {
73 | current = parseKeyEnd
74 | }
75 | if current == parseBraceKey && len(braceKey) != 0 {
76 | current = parseBraceKeyEnd
77 | } else if current == parseValue || current == parseBraceValue {
78 | value = append(value, t)
79 | }
80 | } else if t == '"' {
81 | if current == parseKeyEnd {
82 | current = parseValue
83 | } else if current == parseBraceKeyEnd {
84 | current = parseBraceValue
85 | } else if current == parseValue {
86 | c.apply(string(key), string(value))
87 | key = []rune{}
88 | value = []rune{}
89 | current = parseKey
90 | } else if current == parseBraceValue {
91 | brace[string(braceKey)] = string(value)
92 | braceKey = []rune{}
93 | value = []rune{}
94 | current = parseBraceKey
95 | } else {
96 | return errors.New("parse error")
97 | }
98 | } else if t == '#' { // TODO: check original parser
99 | if current != parseKey {
100 | break parseLine
101 | } else if current == parseKey && len(key) == 0 {
102 | break parseLine
103 | } else {
104 | return errors.New("parse error")
105 | }
106 | } else if t == '{' {
107 | current = parseBraceKey
108 | break parseLine
109 | } else if t == '}' {
110 | c.applyMap(string(key), brace)
111 | key = []rune{}
112 | brace = map[string]string{}
113 | current = parseKey
114 | } else {
115 | if current == parseKey {
116 | key = append(key, t)
117 | } else if current == parseBraceKey {
118 | braceKey = append(braceKey, t)
119 | } else {
120 | value = append(value, t)
121 | }
122 | }
123 | }
124 | }
125 | return nil
126 | }
127 |
128 | func (c *Config) apply(key, value string) {
129 | if key == "music_directory" {
130 | c.MusicDirectory = value
131 | }
132 | }
133 |
134 | func (c *Config) applyMap(key string, value map[string]string) {
135 | if key == "audio_output" {
136 | a := &ConfigAudioOutput{}
137 | a.Type = value["type"]
138 | a.Name = value["name"]
139 | a.Port = value["port"]
140 | c.AudioOutputs = append(c.AudioOutputs, a)
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/internal/songs/sort.go:
--------------------------------------------------------------------------------
1 | package songs
2 |
3 | import "sort"
4 |
5 | // SortEqual compares song filepath is equal
6 | func SortEqual(o, n []map[string][]string) bool {
7 | if len(o) != len(n) {
8 | return false
9 | }
10 | for i := range n {
11 | f1 := o[i]["file"][0]
12 | f2 := n[i]["file"][0]
13 | if f1 != f2 {
14 | return false
15 | }
16 | }
17 | return true
18 | }
19 |
20 | type sorter struct {
21 | song map[string][]string
22 | keys map[string]*string
23 | sortkey string
24 | target bool
25 | }
26 |
27 | func sortKeys(s map[string][]string, keys []string) []*sorter {
28 | sp := []*sorter{{song: s, keys: make(map[string]*string, len(keys))}}
29 | for _, key := range keys {
30 | sp = addAllKeys(sp, key, Tags(s, key))
31 | }
32 | return sp
33 | }
34 |
35 | func addAllKeys(sp []*sorter, key string, add []string) []*sorter {
36 | if len(add) == 0 {
37 | for i := range sp {
38 | sp[i].sortkey = sp[i].sortkey + " "
39 | sp[i].keys[key] = nil
40 | }
41 | return sp
42 | }
43 | if len(add) == 1 {
44 | for i := range sp {
45 | sp[i].sortkey = sp[i].sortkey + add[0]
46 | sp[i].keys[key] = &add[0]
47 | }
48 | return sp
49 | }
50 | newsp := make([]*sorter, len(sp)*len(add))
51 | index := 0
52 | for i := range sp {
53 | for j := range add {
54 | s := &sorter{song: sp[i].song, keys: make(map[string]*string, len(sp[i].keys))}
55 | for k := range sp[i].keys {
56 | s.keys[k] = sp[i].keys[k]
57 | }
58 | s.sortkey = s.sortkey + add[j]
59 | s.keys[key] = &add[j]
60 | newsp[index] = s
61 | index++
62 | }
63 | }
64 | return newsp
65 | }
66 |
67 | // WeakFilterSort sorts songs by song tag list.
68 | func WeakFilterSort(s []map[string][]string, keys []string, filters [][2]*string, must, max, pos int) ([]map[string][]string, [][2]*string, int) {
69 | flatten := flat(s, keys)
70 | sort.Slice(flatten, func(i, j int) bool {
71 | return flatten[i].sortkey < flatten[j].sortkey
72 | })
73 | if pos < len(flatten) && pos >= 0 {
74 | flatten[pos].target = true
75 | }
76 | flatten, used := weakFilterSongs(flatten, filters, must, max)
77 | ret := make([]map[string][]string, len(flatten))
78 | newpos := -1
79 | for i, sorter := range flatten {
80 | ret[i] = sorter.song
81 | if sorter.target {
82 | newpos = i
83 | }
84 | }
85 | return ret, used, newpos
86 | }
87 |
88 | func flat(s []map[string][]string, keys []string) []*sorter {
89 | flatten := make([]*sorter, 0, len(s))
90 | for _, song := range s {
91 | flatten = append(flatten, sortKeys(song, keys)...)
92 | }
93 | return flatten
94 | }
95 |
96 | // weakFilterSongs removes songs if not matched by filters until len(songs) over max.
97 | // filters example: [][]string{[]string{"Artist", "foo"}}
98 | func weakFilterSongs(s []*sorter, filters [][2]*string, must, max int) ([]*sorter, [][2]*string) {
99 | used := [][2]*string{}
100 | if len(s) <= max && must == 0 {
101 | return s, used
102 | }
103 | n := s
104 | for i, filter := range filters {
105 | if len(n) <= max && must <= i {
106 | break
107 | }
108 | used = append(used, filter)
109 | nc := make([]*sorter, 0, len(n))
110 | for _, sorter := range n {
111 | key, want := filter[0], filter[1]
112 | if key == nil {
113 | nc = append(nc, sorter)
114 | } else if value := sorter.keys[*key]; (value != nil && want != nil && *value == *want) || (value == nil && want == nil) {
115 | nc = append(nc, sorter)
116 | }
117 | }
118 | n = nc
119 | }
120 | if len(n) > max {
121 | nc := make([]*sorter, max)
122 | for i := range n {
123 | if i < max {
124 | nc[i] = n[i]
125 | }
126 | }
127 | return nc, used
128 | }
129 | return n, used
130 | }
131 |
132 | func strPtr(s string) *string { return &s }
133 |
--------------------------------------------------------------------------------
/internal/vv/api/playlist_songs_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "net/http/httptest"
9 | "reflect"
10 | "testing"
11 |
12 | "github.com/meiraka/vv/internal/vv/api"
13 | )
14 |
15 | func TestPlaylistSongsHandlerGET(t *testing.T) {
16 | songsHook, randValue := testSongsHook()
17 | for label, tt := range map[string][]struct {
18 | label string
19 | playlistInfo func(*testing.T) ([]map[string][]string, error)
20 | err error
21 | want string
22 | cache []map[string][]string
23 | changed bool
24 | }{
25 | `empty`: {{
26 | playlistInfo: func(t *testing.T) ([]map[string][]string, error) {
27 | return []map[string][]string{}, nil
28 | },
29 | want: `[]`,
30 | cache: []map[string][]string{},
31 | }},
32 | `exists`: {{
33 | playlistInfo: func(t *testing.T) ([]map[string][]string, error) {
34 | return []map[string][]string{{"file": {"/foo/bar.mp3"}}}, nil
35 | },
36 | want: fmt.Sprintf(`[{"%s":["%s"],"file":["/foo/bar.mp3"]}]`, randValue, randValue),
37 | cache: []map[string][]string{{"file": {"/foo/bar.mp3"}, randValue: {randValue}}},
38 | changed: true,
39 | }},
40 | `error`: {{
41 | label: "prepare data",
42 | playlistInfo: func(t *testing.T) ([]map[string][]string, error) {
43 | return []map[string][]string{{"file": {"/foo/bar.mp3"}}}, nil
44 | },
45 | want: fmt.Sprintf(`[{"%s":["%s"],"file":["/foo/bar.mp3"]}]`, randValue, randValue),
46 | cache: []map[string][]string{{"file": {"/foo/bar.mp3"}, randValue: {randValue}}},
47 | changed: true,
48 | }, {
49 | label: "error",
50 | playlistInfo: func(t *testing.T) ([]map[string][]string, error) {
51 | t.Helper()
52 | return nil, errTest
53 | },
54 | err: errTest,
55 | want: fmt.Sprintf(`[{"%s":["%s"],"file":["/foo/bar.mp3"]}]`, randValue, randValue),
56 | cache: []map[string][]string{{"file": {"/foo/bar.mp3"}, randValue: {randValue}}},
57 | }},
58 | } {
59 | t.Run(label, func(t *testing.T) {
60 | mpd := &mpdPlaylistSongs{t: t}
61 |
62 | h, err := api.NewPlaylistSongsHandler(mpd, songsHook)
63 | if err != nil {
64 | t.Fatalf("api.NewPlaylistSongs() = %v, %v", h, err)
65 | }
66 | for i := range tt {
67 | f := func(t *testing.T) {
68 | mpd.playlistInfo = tt[i].playlistInfo
69 | if err := h.Update(context.TODO()); !errors.Is(err, tt[i].err) {
70 | t.Errorf("handler.Update(context.TODO()) = %v; want %v", err, tt[i].err)
71 | }
72 |
73 | r := httptest.NewRequest(http.MethodGet, "/", nil)
74 | w := httptest.NewRecorder()
75 | h.ServeHTTP(w, r)
76 | if status, got := w.Result().StatusCode, w.Body.String(); status != http.StatusOK || got != tt[i].want {
77 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, http.StatusOK, tt[i].want)
78 | }
79 | if cache := h.Cache(); !reflect.DeepEqual(cache, tt[i].cache) {
80 | t.Errorf("got cache\n%v; want\n%v", cache, tt[i].cache)
81 | }
82 | if changed := recieveMsg(h.Changed()); changed != tt[i].changed {
83 | t.Errorf("changed = %v; want %v", changed, tt[i].changed)
84 | }
85 | }
86 | if len(tt) != 1 {
87 | t.Run(tt[i].label, f)
88 | } else {
89 | f(t)
90 | }
91 | }
92 | })
93 | }
94 |
95 | }
96 |
97 | type mpdPlaylistSongs struct {
98 | t *testing.T
99 | playlistInfo func(*testing.T) ([]map[string][]string, error)
100 | }
101 |
102 | func (m *mpdPlaylistSongs) PlaylistInfo(ctx context.Context) ([]map[string][]string, error) {
103 | m.t.Helper()
104 | if m.playlistInfo == nil {
105 | m.t.Fatal("no PlaylistInfo mock function")
106 | }
107 | return m.playlistInfo(m.t)
108 | }
109 |
--------------------------------------------------------------------------------
/internal/vv/api/storage.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/meiraka/vv/internal/mpd"
11 | )
12 |
13 | type httpStorage struct {
14 | URI *string `json:"uri,omitempty"`
15 | Updating bool `json:"updating,omitempty"`
16 | }
17 |
18 | // MPDStorage represents mpd api for Storage API.
19 | type MPDStorage interface {
20 | ListMounts(context.Context) ([]map[string]string, error)
21 | Mount(context.Context, string, string) error
22 | Unmount(context.Context, string) error
23 | Update(context.Context, string) (map[string]string, error)
24 | }
25 |
26 | // StorageHandler provides mount, unmount, list storage api.
27 | type StorageHandler struct {
28 | mpd MPDStorage
29 | cache *cache
30 | logger Logger
31 | }
32 |
33 | func NewStorageHandler(mpd MPDStorage, logger Logger) (*StorageHandler, error) {
34 | c, err := newCache(map[string]*httpStorage{})
35 | if err != nil {
36 | return nil, err
37 | }
38 | return &StorageHandler{
39 | mpd: mpd,
40 | cache: c,
41 | logger: logger,
42 | }, nil
43 | }
44 |
45 | func (a *StorageHandler) Update(ctx context.Context) error {
46 | ret := map[string]*httpStorage{}
47 | ms, err := a.mpd.ListMounts(ctx)
48 | if err != nil {
49 | // skip command error to support old mpd
50 | var perr *mpd.CommandError
51 | if errors.As(err, &perr) {
52 | a.cache.SetIfModified(ret)
53 | a.logger.Debugf("vv/api: storage: %v", err)
54 | return nil
55 | }
56 | return err
57 | }
58 | for _, m := range ms {
59 | ret[m["mount"]] = &httpStorage{
60 | URI: stringPtr(m["storage"]),
61 | }
62 | }
63 | a.cache.SetIfModified(ret)
64 | return nil
65 | }
66 |
67 | // ServeHTTP responses storage api.
68 | func (a *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
69 | if r.Method != "POST" {
70 | a.cache.ServeHTTP(w, r)
71 | return
72 | }
73 | var req map[string]*httpStorage
74 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
75 | writeHTTPError(w, http.StatusBadRequest, err)
76 | return
77 | }
78 | ctx := r.Context()
79 | for k, v := range req {
80 | if k == "" {
81 | writeHTTPError(w, http.StatusBadRequest, errors.New("storage name is empty"))
82 | return
83 | }
84 | if v != nil && v.Updating {
85 | // TODO: This is not intuitive
86 | if _, err := a.mpd.Update(ctx, k); err != nil {
87 | writeHTTPError(w, http.StatusInternalServerError, err)
88 | return
89 | }
90 | // updating request does not affect response and always returns 202
91 | now := time.Now().UTC()
92 | r = setUpdateTime(r, now)
93 | } else if v != nil && v.URI != nil {
94 | if err := a.mpd.Mount(ctx, k, *v.URI); err != nil {
95 | writeHTTPError(w, http.StatusInternalServerError, err)
96 | return
97 | }
98 | now := time.Now().UTC()
99 | r = setUpdateTime(r, now)
100 | if _, err := a.mpd.Update(ctx, k); err != nil {
101 | writeHTTPError(w, http.StatusInternalServerError, err)
102 | return
103 | }
104 | } else {
105 | if err := a.mpd.Unmount(ctx, k); err != nil {
106 | writeHTTPError(w, http.StatusInternalServerError, err)
107 | return
108 | }
109 | now := time.Now().UTC()
110 | r = setUpdateTime(r, now)
111 | if _, err := a.mpd.Update(ctx, ""); err != nil {
112 | writeHTTPError(w, http.StatusInternalServerError, err)
113 | return
114 | }
115 | }
116 | }
117 | r.Method = http.MethodGet
118 | a.cache.ServeHTTP(w, r)
119 | }
120 |
121 | // Changed returns storage list update event chan.
122 | func (a *StorageHandler) Changed() <-chan struct{} {
123 | return a.cache.Changed()
124 | }
125 |
126 | // Close closes update event chan.
127 | func (a *StorageHandler) Close() {
128 | a.cache.Close()
129 | }
130 |
--------------------------------------------------------------------------------
/internal/vv/api/images/image.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "bytes"
5 | "image"
6 | "time"
7 |
8 | _ "image/gif" // support gif cover load
9 | "image/jpeg"
10 | _ "image/png" // support png cover load
11 | "io"
12 | "math"
13 | "mime"
14 | "net/http"
15 | "os"
16 | "path"
17 | "strconv"
18 |
19 | _ "golang.org/x/image/bmp" // support bmp cover load
20 | _ "golang.org/x/image/webp" // support webp cover load
21 |
22 | "golang.org/x/image/draw"
23 | )
24 |
25 | func resizeImage(data io.ReadSeeker, width, height int) ([]byte, error) {
26 | info, _, err := image.DecodeConfig(data)
27 | if err != nil {
28 | return nil, err
29 | }
30 | if _, err := data.Seek(0, io.SeekStart); err != nil {
31 | return nil, err
32 | }
33 | img, _, err := image.Decode(data)
34 | if err != nil {
35 | return nil, err
36 | }
37 | imgRatio := float64(info.Width) / float64(info.Height)
38 | outRatio := float64(width) / float64(height)
39 | if imgRatio > outRatio {
40 | height = int(math.Round(float64(height*info.Height) / float64(info.Width)))
41 | } else {
42 | width = int(math.Round(float64(width*info.Width) / float64(info.Height)))
43 | }
44 | rect := image.Rect(0, 0, width, height)
45 | out := image.NewRGBA(rect)
46 | draw.CatmullRom.Scale(out, rect, img, img.Bounds(), draw.Over, nil)
47 | outwriter := new(bytes.Buffer)
48 | opt := jpeg.Options{Quality: 100}
49 | jpeg.Encode(outwriter, out, &opt)
50 | return outwriter.Bytes(), nil
51 | }
52 |
53 | func serveImage(rpath string, w http.ResponseWriter, r *http.Request) {
54 | i, err := os.Stat(rpath)
55 | if err != nil {
56 | http.NotFound(w, r)
57 | return
58 | }
59 | l := i.ModTime().UTC()
60 | if !modifiedSince(r, l) {
61 | w.WriteHeader(http.StatusNotModified)
62 | return
63 | }
64 | f, err := os.Open(rpath)
65 | if err != nil {
66 | http.NotFound(w, r)
67 | return
68 | }
69 | defer f.Close()
70 | q := r.URL.Query()
71 | ws, hs := q.Get("width"), q.Get("height")
72 | if len(ws) == 0 || len(hs) == 0 {
73 | if r.URL.Query().Get("d") != "" || q.Get("v") != "" {
74 | w.Header().Add("Cache-Control", "max-age=31536000")
75 | } else {
76 | w.Header().Add("Cache-Control", "max-age=86400")
77 | }
78 | w.Header().Add("Content-Length", strconv.FormatInt(i.Size(), 10))
79 | w.Header().Add("Content-Type", mime.TypeByExtension(path.Ext(rpath)))
80 | w.Header().Add("Last-Modified", l.Format(http.TimeFormat))
81 | io.CopyN(w, f, i.Size())
82 | return
83 | }
84 | wi, err := strconv.Atoi(ws)
85 | if err != nil {
86 | w.WriteHeader(http.StatusBadRequest)
87 | return
88 | }
89 | hi, err := strconv.Atoi(hs)
90 | if err != nil {
91 | w.WriteHeader(http.StatusBadRequest)
92 | return
93 | }
94 | b, err := resizeImage(f, wi, hi)
95 | if err != nil {
96 | w.WriteHeader(http.StatusInternalServerError)
97 | return
98 | }
99 | if q := r.URL.Query(); q.Get("d") != "" || q.Get("v") != "" {
100 | w.Header().Add("Cache-Control", "max-age=31536000")
101 | } else {
102 | w.Header().Add("Cache-Control", "max-age=86400")
103 | }
104 | w.Header().Add("Content-Length", strconv.Itoa(len(b)))
105 | w.Header().Add("Content-Type", mime.TypeByExtension(path.Ext(rpath)))
106 | w.Header().Add("Last-Modified", l.Format(http.TimeFormat))
107 | w.Write(b)
108 | }
109 |
110 | // ext guesses image extention by binary.
111 | func ext(b []byte) (string, error) {
112 | _, format, err := image.DecodeConfig(bytes.NewReader(b))
113 | return format, err
114 | }
115 |
116 | /*modifiedSince compares If-Modified-Since header given time.Time.*/
117 | func modifiedSince(r *http.Request, l time.Time) bool {
118 | t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since"))
119 | if err != nil {
120 | return true
121 | }
122 | return !l.Before(t.Add(time.Second))
123 | }
124 |
--------------------------------------------------------------------------------
/internal/mpd/commandlist_test.go:
--------------------------------------------------------------------------------
1 | package mpd
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "sync"
7 | "testing"
8 | "time"
9 |
10 | "github.com/meiraka/vv/internal/mpd/mpdtest"
11 | )
12 |
13 | func TestCommandList(t *testing.T) {
14 | ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
15 | defer cancel()
16 | ts := mpdtest.NewServer("OK MPD 0.19")
17 | defer ts.Close()
18 | go func() {
19 | ts.Expect(ctx, &mpdtest.WR{Read: "password \"2434\"\n", Write: "OK\n"})
20 | ts.Expect(ctx, &mpdtest.WR{Read: "command_list_ok_begin\n"})
21 | ts.Expect(ctx, &mpdtest.WR{Read: "clear\n"})
22 | ts.Expect(ctx, &mpdtest.WR{Read: "add \"/foo/bar\"\n"})
23 | ts.Expect(ctx, &mpdtest.WR{Read: "command_list_end\n", Write: "list_OK\nlist_OK\nOK\n"})
24 | }()
25 | c, err := Dial("tcp", ts.URL,
26 | &ClientOptions{Password: "2434", Timeout: testTimeout, ReconnectionInterval: time.Millisecond})
27 | if err != nil {
28 | t.Fatalf("Dial got error %v; want nil", err)
29 | }
30 | cl := &CommandList{}
31 | cl.Clear()
32 | cl.Add("/foo/bar")
33 | if err := c.ExecCommandList(ctx, cl); err != nil {
34 | t.Errorf("CommandList got error %v; want nil", err)
35 | }
36 | if err := c.Close(ctx); err != nil {
37 | t.Errorf("Close got error %v; want nil", err)
38 | }
39 | }
40 |
41 | func TestCommandListCommandError(t *testing.T) {
42 | ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
43 | defer cancel()
44 | ts := mpdtest.NewServer("OK MPD 0.19")
45 | defer ts.Close()
46 | go func() {
47 | ts.Expect(ctx, &mpdtest.WR{Read: "password \"2434\"\n", Write: "OK\n"})
48 | ts.Expect(ctx, &mpdtest.WR{Read: "command_list_ok_begin\n"})
49 | ts.Expect(ctx, &mpdtest.WR{Read: "clear\n"})
50 | ts.Expect(ctx, &mpdtest.WR{Read: "play 0\n"})
51 | ts.Expect(ctx, &mpdtest.WR{Read: "add \"/foo/bar\"\n"})
52 | ts.Expect(ctx, &mpdtest.WR{Read: "command_list_end\n", Write: "list_OK\nACK [2@1] {} Bad song index\n"})
53 | }()
54 | c, err := Dial("tcp", ts.URL,
55 | &ClientOptions{Password: "2434", Timeout: testTimeout, ReconnectionInterval: time.Millisecond})
56 | if err != nil {
57 | t.Fatalf("Dial got error %v; want nil", err)
58 | }
59 | cl := &CommandList{}
60 | cl.Clear()
61 | cl.Play(0)
62 | cl.Add("/foo/bar")
63 | want := &CommandError{ID: 2, Index: 1, Message: "Bad song index"}
64 | if err := c.ExecCommandList(ctx, cl); !errors.Is(err, want) {
65 | t.Errorf("CommandList got error %v; want %v", err, want)
66 | }
67 | if err := c.Close(ctx); err != nil {
68 | t.Errorf("Close got error %v; want nil", err)
69 | }
70 | }
71 |
72 | func TestCommandListNetworkError(t *testing.T) {
73 | ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
74 | defer cancel()
75 | ts := mpdtest.NewServer("OK MPD 0.19")
76 | defer ts.Close()
77 | wg := sync.WaitGroup{}
78 | wg.Add(1)
79 | go func() {
80 | defer wg.Done()
81 | ts.Expect(ctx, &mpdtest.WR{Read: "password \"2434\"\n", Write: "OK\n"})
82 | ts.Expect(ctx, &mpdtest.WR{Read: "command_list_ok_begin\n"})
83 | ts.Expect(ctx, &mpdtest.WR{Read: "clear\n"})
84 | ts.Expect(ctx, &mpdtest.WR{Read: "play 0\n"})
85 | ts.Expect(ctx, &mpdtest.WR{Read: "add \"/foo/bar\"\n"})
86 | ts.Expect(ctx, &mpdtest.WR{Read: "command_list_end\n"})
87 | ts.Disconnect(ctx)
88 | ts.Expect(ctx, &mpdtest.WR{Read: "password \"2434\"\n", Write: "OK\n"})
89 | }()
90 | c, err := Dial("tcp", ts.URL,
91 | &ClientOptions{Password: "2434", Timeout: testTimeout, ReconnectionInterval: time.Millisecond})
92 | if err != nil {
93 | t.Fatalf("Dial got error %v; want nil", err)
94 | }
95 | cl := &CommandList{}
96 | cl.Clear()
97 | cl.Play(0)
98 | cl.Add("/foo/bar")
99 | if err := c.ExecCommandList(ctx, cl); err == nil {
100 | t.Error("CommandList got nil; want non nil error at network error")
101 | }
102 | wg.Wait()
103 | if err := c.Close(ctx); err != nil {
104 | t.Errorf("Close got error %v; want nil", err)
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/internal/vv/api/cache.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 | "strconv"
10 | "strings"
11 | "sync"
12 | "time"
13 |
14 | "github.com/meiraka/vv/internal/gzip"
15 | "github.com/meiraka/vv/internal/request"
16 | )
17 |
18 | type cache struct {
19 | changed chan struct{}
20 | changedB bool
21 | json []byte
22 | gzjson []byte
23 | date time.Time
24 | mu sync.RWMutex
25 | }
26 |
27 | func newCache(i interface{}) (*cache, error) {
28 | b, gz, err := cacheBinary(i)
29 | if err != nil {
30 | return nil, err
31 | }
32 | c := &cache{
33 | changed: make(chan struct{}, 1),
34 | changedB: true,
35 | json: b,
36 | gzjson: gz,
37 | date: time.Now().UTC(),
38 | }
39 | return c, nil
40 | }
41 |
42 | func (c *cache) Close() {
43 | c.mu.Lock()
44 | if c.changedB {
45 | close(c.changed)
46 | c.changedB = false
47 | }
48 | c.mu.Unlock()
49 | }
50 |
51 | func (c *cache) Changed() <-chan struct{} {
52 | return c.changed
53 | }
54 |
55 | func (c *cache) Set(i interface{}) error {
56 | _, err := c.set(i, true)
57 | return err
58 | }
59 |
60 | func (c *cache) SetIfModified(i interface{}) (changed bool, err error) {
61 | return c.set(i, false)
62 | }
63 |
64 | func (c *cache) get() ([]byte, []byte, time.Time) {
65 | c.mu.RLock()
66 | defer c.mu.RUnlock()
67 | return c.json, c.gzjson, c.date
68 | }
69 |
70 | func (c *cache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
71 | c.mu.RLock()
72 | b, gz, date := c.json, c.gzjson, c.date
73 | c.mu.RUnlock()
74 | etag := fmt.Sprintf(`"%d.%d"`, date.Unix(), date.Nanosecond())
75 | if request.NoneMatch(r, etag) {
76 | w.WriteHeader(http.StatusNotModified)
77 | return
78 | }
79 | if !request.ModifiedSince(r, date) {
80 | w.WriteHeader(http.StatusNotModified)
81 | return
82 | }
83 | w.Header().Add("Cache-Control", "max-age=0")
84 | w.Header().Add("Content-Type", "application/json; charset=utf-8")
85 | w.Header().Add("Last-Modified", date.Format(http.TimeFormat))
86 | w.Header().Add("Vary", "Accept-Encoding")
87 | w.Header().Add("ETag", etag)
88 | status := http.StatusOK
89 | if getUpdateTime(r).After(date) {
90 | status = http.StatusAccepted
91 | }
92 | if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && gz != nil {
93 | w.Header().Add("Content-Encoding", "gzip")
94 | w.Header().Add("Content-Length", strconv.Itoa(len(gz)))
95 | w.WriteHeader(status)
96 | w.Write(gz)
97 | return
98 | }
99 | w.Header().Add("Content-Length", strconv.Itoa(len(b)))
100 | w.WriteHeader(status)
101 | w.Write(b)
102 | }
103 |
104 | func (c *cache) set(i interface{}, force bool) (bool, error) {
105 | n, gz, err := cacheBinary(i)
106 | if err != nil {
107 | return false, err
108 | }
109 | c.mu.Lock()
110 | defer c.mu.Unlock()
111 |
112 | o := c.json
113 | if force || !bytes.Equal(o, n) {
114 | c.json = n
115 | c.date = time.Now().UTC()
116 | c.gzjson = gz
117 | if c.changedB {
118 | select {
119 | case c.changed <- struct{}{}:
120 | default:
121 | }
122 | }
123 | return true, nil
124 | }
125 | return false, nil
126 | }
127 |
128 | func cacheBinary(i interface{}) ([]byte, []byte, error) {
129 | n, err := json.Marshal(i)
130 | if err != nil {
131 | return nil, nil, err
132 | }
133 | gz, err := gzip.Encode(n)
134 | if err != nil {
135 | return n, nil, nil
136 | }
137 | return n, gz, nil
138 | }
139 |
140 | type httpContextKey string
141 |
142 | const httpUpdateTime = httpContextKey("updateTime")
143 |
144 | func getUpdateTime(r *http.Request) time.Time {
145 | if v := r.Context().Value(httpUpdateTime); v != nil {
146 | if i, ok := v.(time.Time); ok {
147 | return i
148 | }
149 | }
150 | return time.Time{}
151 | }
152 |
153 | func setUpdateTime(r *http.Request, u time.Time) *http.Request {
154 | ctx := context.WithValue(r.Context(), httpUpdateTime, u)
155 | return r.WithContext(ctx)
156 | }
157 |
--------------------------------------------------------------------------------
/internal/vv/api/images/remote.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "path"
8 | "strings"
9 |
10 | "github.com/meiraka/vv/internal/mpd"
11 | )
12 |
13 | // Remote provides http album art server from mpd albumart api.
14 | type Remote struct {
15 | httpPrefix string
16 | cache *cache
17 | client *mpd.Client
18 | }
19 |
20 | // NewRemote initializes Remote with cacheDir.
21 | func NewRemote(httpPrefix string, client *mpd.Client, cacheDir string) (*Remote, error) {
22 | cache, err := newCache(cacheDir)
23 | if err != nil {
24 | return nil, err
25 | }
26 | s := &Remote{
27 | httpPrefix: httpPrefix,
28 | cache: cache,
29 | client: client,
30 | }
31 | return s, nil
32 | }
33 |
34 | // Update rescans song images if not indexed.
35 | func (s *Remote) Update(ctx context.Context, song map[string][]string) error {
36 | if ok, err := s.isSupported(ctx); err != nil {
37 | return err
38 | } else if !ok {
39 | return nil
40 | }
41 | key, file, ok := s.key(song)
42 | if !ok {
43 | return nil
44 | }
45 | if _, ok := s.cache.GetLastRequestID(key); ok {
46 | return nil
47 | }
48 | if err := s.updateCache(ctx, key, file, ""); err != nil {
49 | // reduce errors for same key
50 | s.cache.SetEmpty(key, "")
51 | return err
52 | }
53 | return nil
54 | }
55 |
56 | // Rescan rescans song images.
57 | func (s *Remote) Rescan(ctx context.Context, song map[string][]string, reqid string) error {
58 | if ok, err := s.isSupported(ctx); err != nil {
59 | return err
60 | } else if !ok {
61 | return nil
62 | }
63 |
64 | key, file, ok := s.key(song)
65 | if !ok {
66 | return nil
67 | }
68 | id, ok := s.cache.GetLastRequestID(key)
69 | if ok && id == reqid {
70 | return nil
71 | }
72 | if err := s.updateCache(ctx, key, file, reqid); err != nil {
73 | return err
74 | }
75 | return nil
76 | }
77 |
78 | // Close finalizes cache db, coroutines.
79 | func (s *Remote) Close() error {
80 | return s.cache.Close()
81 | }
82 |
83 | // ServeHTTP serves local cover art with httpPrefix
84 | func (s *Remote) ServeHTTP(w http.ResponseWriter, r *http.Request) {
85 | // strip httpPrefix
86 | p := s.httpPrefix
87 | if p[len(p)-1] != '/' {
88 | p += "/"
89 | }
90 | if !strings.HasPrefix(r.URL.Path, p) {
91 | http.NotFound(w, r)
92 | return
93 | }
94 | urlName := r.URL.Path[len(p):]
95 |
96 | // get local filename
97 | path, ok := s.cache.GetLocalPath(urlName)
98 | if !ok {
99 | http.NotFound(w, r)
100 | return
101 | }
102 | serveImage(path, w, r)
103 | }
104 |
105 | // GetURLs returns cover path for song
106 | func (s *Remote) GetURLs(song map[string][]string) ([]string, bool) {
107 | if s == nil {
108 | return nil, true
109 | }
110 | key, _, ok := s.key(song)
111 | if !ok {
112 | return nil, true
113 | }
114 | url, ok := s.cache.GetURL(key)
115 | if !ok {
116 | return nil, false
117 | }
118 | if len(url) == 0 {
119 | return nil, true
120 | }
121 | return []string{path.Join(s.httpPrefix, url)}, true
122 | }
123 |
124 | func (s *Remote) isSupported(ctx context.Context) (bool, error) {
125 | cmds, err := s.client.Commands(ctx)
126 | if err != nil {
127 | return false, err
128 | }
129 | for _, cmd := range cmds {
130 | if cmd == "albumart" {
131 | return true, nil
132 | }
133 | }
134 | return false, nil
135 | }
136 |
137 | // key returns cache key and albumart command file path.
138 | func (s *Remote) key(song map[string][]string) (key, file string, ok bool) {
139 | f, ok := song["file"]
140 | if !ok {
141 | return "", "", false
142 | }
143 | if len(f) != 1 {
144 | return "", "", false
145 | }
146 | return path.Dir(f[0]), f[0], true
147 | }
148 |
149 | func (s *Remote) updateCache(ctx context.Context, key, file, reqid string) error {
150 | b, err := s.client.AlbumArt(ctx, file)
151 | if err != nil {
152 | // set zero value for not found
153 | if errors.Is(err, mpd.ErrNoExist) {
154 | return s.cache.SetEmpty(key, reqid)
155 | }
156 | return err
157 | }
158 | return s.cache.Set(key, reqid, b)
159 | }
160 |
--------------------------------------------------------------------------------
/internal/vv/api/neighbors_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "net/http/httptest"
8 | "testing"
9 |
10 | "github.com/meiraka/vv/internal/log"
11 | "github.com/meiraka/vv/internal/mpd"
12 | "github.com/meiraka/vv/internal/vv/api"
13 | )
14 |
15 | func TestNeighborsHandlerGET(t *testing.T) {
16 | for label, tt := range map[string][]struct {
17 | label string
18 | listNeighbors func() ([]map[string]string, error)
19 | err error
20 | want string
21 | changed bool
22 | }{
23 | "ok": {{
24 | label: "empty",
25 | listNeighbors: func() ([]map[string]string, error) {
26 | return []map[string]string{}, nil
27 | },
28 | want: "{}",
29 | }, {
30 | label: "some data",
31 | listNeighbors: func() ([]map[string]string, error) {
32 | return []map[string]string{
33 | {
34 | "neighbor": "smb://FOO",
35 | "name": "FOO (Samba 4.1.11-Debian)",
36 | },
37 | }, nil
38 | },
39 | want: `{"FOO (Samba 4.1.11-Debian)":{"uri":"smb://FOO"}}`,
40 | changed: true,
41 | }, {
42 | label: "remove",
43 | listNeighbors: func() ([]map[string]string, error) {
44 | return []map[string]string{}, nil
45 | },
46 | want: "{}",
47 | changed: true,
48 | }},
49 | "error/network": {{
50 | label: "prepare data",
51 | listNeighbors: func() ([]map[string]string, error) {
52 | return []map[string]string{
53 | {
54 | "neighbor": "smb://FOO",
55 | "name": "FOO (Samba 4.1.11-Debian)",
56 | },
57 | }, nil
58 | },
59 | want: `{"FOO (Samba 4.1.11-Debian)":{"uri":"smb://FOO"}}`,
60 | changed: true,
61 | }, {
62 | label: "error",
63 | listNeighbors: func() ([]map[string]string, error) {
64 | return nil, errTest
65 | },
66 | err: errTest,
67 | want: `{"FOO (Samba 4.1.11-Debian)":{"uri":"smb://FOO"}}`,
68 | }},
69 | "error/mpd": {{
70 | label: "prepare data",
71 | listNeighbors: func() ([]map[string]string, error) {
72 | return []map[string]string{
73 | {
74 | "neighbor": "smb://FOO",
75 | "name": "FOO (Samba 4.1.11-Debian)",
76 | },
77 | }, nil
78 | },
79 | want: `{"FOO (Samba 4.1.11-Debian)":{"uri":"smb://FOO"}}`,
80 | changed: true,
81 | }, {
82 | label: "unknown command",
83 | listNeighbors: func() ([]map[string]string, error) {
84 | return nil, &mpd.CommandError{ID: 5, Index: 0, Command: "listneighbors", Message: "unknown command \"listneighbors\""}
85 | },
86 | want: "{}",
87 | changed: true,
88 | }},
89 | } {
90 | t.Run(label, func(t *testing.T) {
91 | mpd := &mpdNeighbors{t: t}
92 | h, err := api.NewNeighborsHandler(mpd, log.NewTestLogger(t))
93 | if err != nil {
94 | t.Fatalf("failed to init Neighbors: %v", err)
95 | }
96 | for i := range tt {
97 | t.Run(tt[i].label, func(t *testing.T) {
98 | mpd.t = t
99 | mpd.listNeighbors = tt[i].listNeighbors
100 | if err := h.Update(context.TODO()); !errors.Is(err, tt[i].err) {
101 | t.Errorf("Update(ctx) = %v; want %v", err, tt[i].err)
102 | }
103 | r := httptest.NewRequest(http.MethodGet, "/", nil)
104 | w := httptest.NewRecorder()
105 | h.ServeHTTP(w, r)
106 | if status, got := w.Result().StatusCode, w.Body.String(); status != http.StatusOK || got != tt[i].want {
107 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, http.StatusOK, tt[i].want)
108 | }
109 | if changed := recieveMsg(h.Changed()); changed != tt[i].changed {
110 | t.Errorf("changed = %v; want %v", changed, tt[i].changed)
111 | }
112 | })
113 | }
114 | })
115 | }
116 | }
117 |
118 | type mpdNeighbors struct {
119 | t *testing.T
120 | listNeighbors func() ([]map[string]string, error)
121 | }
122 |
123 | func (m *mpdNeighbors) ListNeighbors(ctx context.Context) ([]map[string]string, error) {
124 | m.t.Helper()
125 | if m.listNeighbors == nil {
126 | m.t.Fatal("no ListNeighbors mock function")
127 | }
128 | return m.listNeighbors()
129 | }
130 |
--------------------------------------------------------------------------------
/internal/vv/api/images/embed.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "path"
7 | "strings"
8 |
9 | "github.com/meiraka/vv/internal/mpd"
10 | "github.com/meiraka/vv/internal/songs"
11 | )
12 |
13 | // Embed provides http album art server from mpd readpicture api.
14 | type Embed struct {
15 | httpPrefix string
16 | cache *cache
17 | client *mpd.Client
18 | }
19 |
20 | // NewEmbed initializes Embed Cover Art provider with cacheDir.
21 | func NewEmbed(httpPrefix string, client *mpd.Client, cacheDir string) (*Embed, error) {
22 | cache, err := newCache(cacheDir)
23 | if err != nil {
24 | return nil, err
25 | }
26 | s := &Embed{
27 | httpPrefix: httpPrefix,
28 | cache: cache,
29 | client: client,
30 | }
31 | return s, nil
32 | }
33 |
34 | // Update rescans song images if not indexed.
35 | func (s *Embed) Update(ctx context.Context, song map[string][]string) error {
36 | if ok, err := s.isSupported(ctx); err != nil {
37 | return err
38 | } else if !ok {
39 | return nil
40 | }
41 | key, file, ok := s.key(song)
42 | if !ok {
43 | return nil
44 | }
45 | if _, ok := s.cache.GetLastRequestID(key); ok {
46 | return nil
47 | }
48 | if err := s.updateCache(ctx, key, file, ""); err != nil {
49 | // reduce errors for same key
50 | s.cache.SetEmpty(key, "")
51 | return err
52 | }
53 | return nil
54 | }
55 |
56 | // Rescan rescans song images.
57 | func (s *Embed) Rescan(ctx context.Context, song map[string][]string, reqid string) error {
58 | if ok, err := s.isSupported(ctx); err != nil {
59 | return err
60 | } else if !ok {
61 | return nil
62 | }
63 |
64 | key, file, ok := s.key(song)
65 | if !ok {
66 | return nil
67 | }
68 | id, ok := s.cache.GetLastRequestID(key)
69 | if ok && id == reqid {
70 | return nil
71 | }
72 | if err := s.updateCache(ctx, key, file, reqid); err != nil {
73 | return err
74 | }
75 | return nil
76 | }
77 |
78 | // Close finalizes cache db, coroutines.
79 | func (s *Embed) Close() error {
80 | return s.cache.Close()
81 | }
82 |
83 | // ServeHTTP serves local cover art with httpPrefix
84 | func (s *Embed) ServeHTTP(w http.ResponseWriter, r *http.Request) {
85 | // strip httpPrefix
86 | p := s.httpPrefix
87 | if p[len(p)-1] != '/' {
88 | p += "/"
89 | }
90 | if !strings.HasPrefix(r.URL.Path, p) {
91 | http.NotFound(w, r)
92 | return
93 | }
94 | urlName := r.URL.Path[len(p):]
95 |
96 | // get local filename
97 | path, ok := s.cache.GetLocalPath(urlName)
98 | if !ok {
99 | http.NotFound(w, r)
100 | return
101 | }
102 | serveImage(path, w, r)
103 | }
104 |
105 | // GetURLs returns cover path for song
106 | func (s *Embed) GetURLs(song map[string][]string) ([]string, bool) {
107 | if s == nil {
108 | return nil, true
109 | }
110 | key, _, ok := s.key(song)
111 | if !ok {
112 | return nil, true
113 | }
114 | url, ok := s.cache.GetURL(key)
115 | if !ok {
116 | return nil, false
117 | }
118 | if len(url) == 0 {
119 | return nil, true
120 | }
121 | return []string{path.Join(s.httpPrefix, url)}, true
122 | }
123 |
124 | func (s *Embed) isSupported(ctx context.Context) (bool, error) {
125 | cmds, err := s.client.Commands(ctx)
126 | if err != nil {
127 | return false, err
128 | }
129 | for _, cmd := range cmds {
130 | if cmd == "readpicture" {
131 | return true, nil
132 | }
133 | }
134 | return false, nil
135 | }
136 |
137 | func (s *Embed) key(song map[string][]string) (key string, path string, ok bool) {
138 | file, ok := song["file"]
139 | if !ok || len(file) != 1 {
140 | return "", "", false
141 | }
142 | path = file[0]
143 | key = strings.Join(songs.Tags(song, "AlbumArtist-Album-Date-Label"), ",")
144 | if len(key) == 0 {
145 | return "", "", false
146 | }
147 | return key, path, true
148 | }
149 |
150 | func (s *Embed) updateCache(ctx context.Context, key, file, reqid string) error {
151 | b, err := s.client.ReadPicture(ctx, file)
152 | if err != nil {
153 | return err
154 | }
155 | if b == nil {
156 | return s.cache.SetEmpty(key, reqid)
157 | }
158 | return s.cache.Set(key, reqid, b)
159 | }
160 |
--------------------------------------------------------------------------------
/internal/vv/api/batch.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "strconv"
7 | "sync"
8 | "time"
9 |
10 | "github.com/meiraka/vv/internal/songs"
11 | )
12 |
13 | var (
14 | // ErrAlreadyShutdown returns if already Shutdown is called
15 | ErrAlreadyShutdown = errors.New("api: already shutdown")
16 | // errAlreadyUpdating returns if already Update is called
17 | errAlreadyUpdating = errors.New("api: update already started")
18 | )
19 |
20 | // ImageProvider represents http cover image image url api.
21 | type ImageProvider interface {
22 | Update(context.Context, map[string][]string) error
23 | Rescan(context.Context, map[string][]string, string) error
24 | GetURLs(map[string][]string) ([]string, bool)
25 | }
26 |
27 | // imgBatch provides background updater for cover image api.
28 | type imgBatch struct {
29 | apis []ImageProvider
30 | sem chan struct{}
31 | e chan bool
32 |
33 | shutdownMu sync.Mutex
34 | shutdownCh chan struct{}
35 | shutdownB bool
36 | logger Logger
37 | }
38 |
39 | // newImgBatch creates Batch from some cover image api.
40 | func newImgBatch(apis []ImageProvider, logger Logger) *imgBatch {
41 | ret := &imgBatch{
42 | apis: apis,
43 | sem: make(chan struct{}, 1),
44 | e: make(chan bool, 2), // 2: first updating/updated event
45 | shutdownCh: make(chan struct{}),
46 | logger: logger,
47 | }
48 | ret.sem <- struct{}{}
49 | return ret
50 | }
51 |
52 | // Event returns event chan which returns bool updating or not.
53 | func (b *imgBatch) Event() <-chan bool {
54 | return b.e
55 | }
56 |
57 | // GetURLs returns images url list.
58 | func (b *imgBatch) GetURLs(song map[string][]string) (urls []string, updated bool) {
59 | allUpdated := true
60 | for _, api := range b.apis {
61 | urls, updated = api.GetURLs(song)
62 | if len(urls) != 0 {
63 | return
64 | }
65 | if !updated {
66 | allUpdated = false
67 | }
68 | }
69 | return urls, allUpdated
70 | }
71 |
72 | var songsTag = songs.Tag
73 |
74 | // Update updates image url database.
75 | func (b *imgBatch) Update(songs []map[string][]string) error {
76 | return b.update(songs, false)
77 | }
78 |
79 | // Update updates image url database.
80 | func (b *imgBatch) Rescan(songs []map[string][]string) error {
81 | return b.update(songs, true)
82 | }
83 |
84 | func (b *imgBatch) update(songs []map[string][]string, force bool) error {
85 | reqID := strconv.FormatInt(time.Now().UnixNano(), 16)
86 | select {
87 | case _, ok := <-b.sem:
88 | if !ok {
89 | return ErrAlreadyShutdown
90 | }
91 | default:
92 | return errAlreadyUpdating
93 | }
94 | select {
95 | case b.e <- true:
96 | default:
97 | }
98 | go func() {
99 | defer func() { b.sem <- struct{}{} }()
100 | ctx, cancel := context.WithCancel(context.Background())
101 | defer cancel()
102 | go func() {
103 | select {
104 | case <-ctx.Done():
105 | case <-b.shutdownCh:
106 | cancel()
107 | }
108 | }()
109 | for _, song := range songs {
110 | for _, c := range b.apis {
111 | if force {
112 | if err := c.Rescan(ctx, song, reqID); err != nil {
113 | b.logger.Printf("vv/api: batch: rescan: %v: %v", songsTag(song, "file"), err)
114 | // use previous rescanned result
115 | }
116 | } else {
117 | if err := c.Update(ctx, song); err != nil {
118 | b.logger.Printf("vv/api: batch: update: %v: %v", songsTag(song, "file"), err)
119 | // use previous rescanned result
120 | }
121 | }
122 | urls, _ := c.GetURLs(song)
123 | if len(urls) > 0 {
124 | break
125 | }
126 | }
127 | }
128 | select {
129 | case <-ctx.Done():
130 | case b.e <- false:
131 | default:
132 | b.logger.Println("vv/api: batch: fixme: event buffer is too small")
133 | }
134 | }()
135 | return nil
136 | }
137 |
138 | // Shutdown gracefully shuts down cover image updater.
139 | func (b *imgBatch) Shutdown(ctx context.Context) error {
140 | b.shutdownMu.Lock()
141 | if !b.shutdownB {
142 | close(b.shutdownCh)
143 | b.shutdownB = true
144 | }
145 | b.shutdownMu.Unlock()
146 | select {
147 | case _, ok := <-b.sem:
148 | if ok {
149 | close(b.sem)
150 | close(b.e)
151 | }
152 | case <-ctx.Done():
153 | return ctx.Err()
154 | }
155 | return nil
156 | }
157 |
--------------------------------------------------------------------------------
/internal/vv/api/currentsong_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "math/rand"
8 | "net/http"
9 | "net/http/httptest"
10 | "sync"
11 | "testing"
12 | "time"
13 |
14 | "github.com/meiraka/vv/internal/vv/api"
15 | )
16 |
17 | func TestCurrentSongHandlerGET(t *testing.T) {
18 | songHook, randValue := testSongHook()
19 | for label, tt := range map[string][]struct {
20 | label string
21 | currentSong func() (map[string][]string, error)
22 | err error
23 | want string
24 | cache map[string][]string
25 | changed bool
26 | }{
27 | "ok": {{
28 | label: "empty",
29 | currentSong: func() (map[string][]string, error) { return map[string][]string{}, nil },
30 | want: fmt.Sprintf(`{"%s":["%s"]}`, randValue, randValue),
31 | cache: map[string][]string{randValue: {randValue}},
32 | changed: true,
33 | }, {
34 | label: "some data",
35 | currentSong: func() (map[string][]string, error) { return map[string][]string{"file": {"/foo/bar.mp3"}}, nil },
36 | want: fmt.Sprintf(`{"%s":["%s"],"file":["/foo/bar.mp3"]}`, randValue, randValue),
37 | cache: map[string][]string{"file": {"/foo/bar.mp3"}, randValue: {randValue}},
38 | changed: true,
39 | }, {
40 | label: "remove",
41 | currentSong: func() (map[string][]string, error) { return map[string][]string{}, nil },
42 | want: fmt.Sprintf(`{"%s":["%s"]}`, randValue, randValue),
43 | cache: map[string][]string{randValue: {randValue}},
44 | changed: true,
45 | }},
46 | "error": {{
47 | label: "prepare data",
48 | currentSong: func() (map[string][]string, error) { return map[string][]string{"file": {"/foo/bar.mp3"}}, nil },
49 | want: fmt.Sprintf(`{"%s":["%s"],"file":["/foo/bar.mp3"]}`, randValue, randValue),
50 | cache: map[string][]string{"file": {"/foo/bar.mp3"}, randValue: {randValue}},
51 | changed: true,
52 | }, {
53 | label: "error",
54 | currentSong: func() (map[string][]string, error) { return nil, errTest },
55 | err: errTest,
56 | want: fmt.Sprintf(`{"%s":["%s"],"file":["/foo/bar.mp3"]}`, randValue, randValue),
57 | cache: map[string][]string{"file": {"/foo/bar.mp3"}, randValue: {randValue}},
58 | }},
59 | } {
60 | t.Run(label, func(t *testing.T) {
61 | mpd := &mpdPlaylistSongsCurrent{}
62 | h, err := api.NewCurrentSongHandler(mpd, songHook)
63 | if err != nil {
64 | t.Fatalf("api.NewPlaylistSongsCurrentHandler() = %v, %v", h, err)
65 | }
66 | for i := range tt {
67 | t.Run(tt[i].label, func(t *testing.T) {
68 | mpd.t = t
69 | mpd.currentSong = tt[i].currentSong
70 | if err := h.Update(context.TODO()); !errors.Is(err, tt[i].err) {
71 | t.Errorf("handler.Update(context.TODO()) = %v; want %v", err, tt[i].err)
72 | }
73 | r := httptest.NewRequest(http.MethodGet, "/", nil)
74 | w := httptest.NewRecorder()
75 | h.ServeHTTP(w, r)
76 | if status, got := w.Result().StatusCode, w.Body.String(); status != http.StatusOK || got != tt[i].want {
77 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, http.StatusOK, tt[i].want)
78 | }
79 | if changed := recieveMsg(h.Changed()); changed != tt[i].changed {
80 | t.Errorf("changed = %v; want %v", changed, tt[i].changed)
81 | }
82 | })
83 | }
84 | })
85 | }
86 | }
87 |
88 | type mpdPlaylistSongsCurrent struct {
89 | t *testing.T
90 | currentSong func() (map[string][]string, error)
91 | }
92 |
93 | func (m *mpdPlaylistSongsCurrent) CurrentSong(context.Context) (map[string][]string, error) {
94 | m.t.Helper()
95 | if m.currentSong == nil {
96 | m.t.Fatal("no CurrentSong mock function")
97 | }
98 | return m.currentSong()
99 | }
100 |
101 | var (
102 | rnd *rand.Rand
103 | rndMu sync.Mutex
104 | )
105 |
106 | func init() {
107 | rnd = rand.New(rand.NewSource(time.Now().UnixNano()))
108 | }
109 |
110 | func testSongHook() (func(s map[string][]string) map[string][]string, string) {
111 | rndMu.Lock()
112 | key := fmt.Sprint(rnd.Int())
113 | rndMu.Unlock()
114 | return func(s map[string][]string) map[string][]string {
115 | s[key] = []string{key}
116 | return s
117 | }, key
118 | }
119 |
--------------------------------------------------------------------------------
/internal/vv/api/outputs.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | "strings"
10 | "time"
11 |
12 | "github.com/meiraka/vv/internal/mpd"
13 | )
14 |
15 | type httpOutput struct {
16 | Name string `json:"name"`
17 | Plugin string `json:"plugin,omitempty"`
18 | Enabled *bool `json:"enabled"`
19 | Attributes *httpOutputAttrbutes `json:"attributes,omitempty"`
20 | Stream string `json:"stream,omitempty"`
21 | }
22 |
23 | type httpOutputAttrbutes struct {
24 | DoP *bool `json:"dop,omitempty"`
25 | AllowedFormats *[]string `json:"allowed_formats,omitempty"`
26 | }
27 |
28 | type MPDOutputs interface {
29 | EnableOutput(context.Context, string) error
30 | DisableOutput(context.Context, string) error
31 | OutputSet(context.Context, string, string, string) error
32 | Outputs(context.Context) ([]*mpd.Output, error)
33 | }
34 |
35 | type OutputsHandler struct {
36 | mpd MPDOutputs
37 | cache *cache
38 | proxy map[string]string
39 | }
40 |
41 | func NewOutputsHandler(mpd MPDOutputs, proxy map[string]string) (*OutputsHandler, error) {
42 | c, err := newCache(map[string]*httpOutput{})
43 | if err != nil {
44 | return nil, err
45 | }
46 | return &OutputsHandler{
47 | mpd: mpd,
48 | cache: c,
49 | proxy: proxy,
50 | }, nil
51 | }
52 |
53 | func (a *OutputsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54 | if r.Method != "POST" {
55 | a.cache.ServeHTTP(w, r)
56 | return
57 | }
58 | var req map[string]*httpOutput
59 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
60 | writeHTTPError(w, http.StatusBadRequest, err)
61 | return
62 | }
63 | ctx := r.Context()
64 | now := time.Now().UTC()
65 | changed := false
66 | for k, v := range req {
67 | if v.Enabled != nil {
68 | var err error
69 | changed = true
70 | if *v.Enabled {
71 | err = a.mpd.EnableOutput(ctx, k)
72 | } else {
73 | err = a.mpd.DisableOutput(ctx, k)
74 | }
75 | if err != nil {
76 | writeHTTPError(w, http.StatusInternalServerError, err)
77 | return
78 | }
79 | }
80 | if v.Attributes != nil {
81 | if v.Attributes.DoP != nil {
82 | changed = true
83 | if err := a.mpd.OutputSet(ctx, k, "dop", btoa(*v.Attributes.DoP, "1", "0")); err != nil {
84 | writeHTTPError(w, http.StatusInternalServerError, err)
85 | return
86 | }
87 | }
88 | if v.Attributes.AllowedFormats != nil {
89 | allowedFormats := *v.Attributes.AllowedFormats
90 | for i := range allowedFormats {
91 | if strings.Contains(allowedFormats[i], " ") {
92 | writeHTTPError(w, http.StatusBadRequest, fmt.Errorf("api: invalid allowed formats: #%d: %q", i, allowedFormats[i]))
93 | return
94 | }
95 | }
96 | changed = true
97 | if err := a.mpd.OutputSet(ctx, k, "allowed_formats", strings.Join(allowedFormats, " ")); err != nil {
98 | writeHTTPError(w, http.StatusInternalServerError, err)
99 | return
100 | }
101 | }
102 | }
103 | }
104 | if changed {
105 | r = setUpdateTime(r, now)
106 | }
107 | r.Method = http.MethodGet
108 | a.cache.ServeHTTP(w, r)
109 |
110 | }
111 |
112 | func (a *OutputsHandler) Update(ctx context.Context) error {
113 | l, err := a.mpd.Outputs(ctx)
114 | if err != nil {
115 | return err
116 | }
117 | data := make(map[string]*httpOutput, len(l))
118 | for _, v := range l {
119 | var stream string
120 | if _, ok := a.proxy[v.Name]; ok {
121 | stream = pathAPIMusicOutputsStream + "?" + url.Values{"name": {v.Name}}.Encode()
122 | }
123 | output := &httpOutput{
124 | Name: v.Name,
125 | Plugin: v.Plugin,
126 | Enabled: &v.Enabled,
127 | Stream: stream,
128 | }
129 | if v.Attributes != nil {
130 | output.Attributes = &httpOutputAttrbutes{}
131 | if dop, ok := v.Attributes["dop"]; ok {
132 | output.Attributes.DoP = boolPtr(dop == "1")
133 | }
134 | if allowedFormats, ok := v.Attributes["allowed_formats"]; ok {
135 | if len(allowedFormats) == 0 {
136 | output.Attributes.AllowedFormats = stringSlicePtr([]string{})
137 | } else {
138 | output.Attributes.AllowedFormats = stringSlicePtr(strings.Split(allowedFormats, " "))
139 | }
140 | }
141 | }
142 | data[v.ID] = output
143 | }
144 | _, err = a.cache.SetIfModified(data)
145 | return err
146 | }
147 |
148 | // Changed returns outputs update event chan.
149 | func (a *OutputsHandler) Changed() <-chan struct{} {
150 | return a.cache.Changed()
151 | }
152 |
153 | // Close closes update event chan.
154 | func (a *OutputsHandler) Close() {
155 | a.cache.Close()
156 | }
157 |
--------------------------------------------------------------------------------
/internal/vv/api/library_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "net/http/httptest"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/meiraka/vv/internal/vv/api"
13 | )
14 |
15 | func TestLibraryHandlerGET(t *testing.T) {
16 | for label, tt := range map[string][]struct {
17 | label string
18 | err error
19 | want string
20 | changed bool
21 | updateStatus *bool
22 | }{
23 | "default": {{
24 | want: `{"updating":false}`,
25 | }},
26 | "updating": {{
27 | label: "false",
28 | updateStatus: boolptr(false),
29 | want: `{"updating":false}`,
30 | }, {
31 | label: "true",
32 | updateStatus: boolptr(true),
33 | want: `{"updating":true}`,
34 | changed: true,
35 | }, {
36 | label: "true->false",
37 | updateStatus: boolptr(false),
38 | want: `{"updating":false}`,
39 | changed: true,
40 | }},
41 | } {
42 | t.Run(label, func(t *testing.T) {
43 | mpd := &mpdLibrary{t: t}
44 | h, err := api.NewLibraryHandler(mpd)
45 | if err != nil {
46 | t.Fatalf("api.NewLibraryHandler(mpd) = %v", err)
47 | }
48 | defer h.Close()
49 | for i := range tt {
50 | f := func(t *testing.T) {
51 | mpd.t = t
52 | if tt[i].updateStatus != nil {
53 | if err := h.UpdateStatus(*tt[i].updateStatus); !errors.Is(err, tt[i].err) {
54 | t.Errorf("handler.UpdateStatus(%v) = %v; want %v", *tt[i].updateStatus, err, tt[i].err)
55 | }
56 | }
57 | r := httptest.NewRequest(http.MethodGet, "/", nil)
58 | w := httptest.NewRecorder()
59 | h.ServeHTTP(w, r)
60 | if status, got := w.Result().StatusCode, w.Body.String(); status != http.StatusOK || got != tt[i].want {
61 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, http.StatusOK, tt[i].want)
62 | }
63 | if changed := recieveMsg(h.Changed()); changed != tt[i].changed {
64 | t.Errorf("changed = %v; want %v", changed, tt[i].changed)
65 | }
66 | }
67 | if len(tt) != 1 {
68 | if tt[i].label == "" {
69 | t.Fatalf("test definition error: no test label")
70 | }
71 | t.Run(tt[i].label, f)
72 | } else {
73 | f(t)
74 | }
75 | }
76 | })
77 | }
78 | }
79 |
80 | func TestLibraryHandlerPOST(t *testing.T) {
81 | for label, tt := range map[string]struct {
82 | body string
83 | wantStatus int
84 | want string
85 | update func(*testing.T, string) (map[string]string, error)
86 | }{
87 | `ok/{"updating":true}`: {
88 | body: `{"updating":true}`,
89 | want: `{"updating":false}`,
90 | wantStatus: http.StatusAccepted,
91 | update: func(t *testing.T, a string) (map[string]string, error) {
92 | t.Helper()
93 | if want := ""; a != want {
94 | t.Errorf("called mpd.Update(ctx, %q); want mpd.Update(ctx, %q)", a, want)
95 | }
96 | return map[string]string{"updating": "1"}, nil
97 | },
98 | },
99 | `error/{"updating":true}`: {
100 | body: `{"updating":true}`,
101 | want: fmt.Sprintf(`{"error":"%s"}`, errTest.Error()),
102 | wantStatus: http.StatusInternalServerError,
103 | update: func(t *testing.T, a string) (map[string]string, error) {
104 | t.Helper()
105 | if want := ""; a != want {
106 | t.Errorf("called mpd.Update(ctx, %q); want mpd.Update(ctx, %q)", a, want)
107 | }
108 | return nil, errTest
109 | },
110 | },
111 | `error/{"updating":false}`: {
112 | body: `{"updating":false}`,
113 | want: `{"error":"requires updating=true"}`,
114 | wantStatus: http.StatusBadRequest,
115 | },
116 | `error/invalid json`: {
117 | body: `invalid json`,
118 | want: `{"error":"invalid character 'i' looking for beginning of value"}`,
119 | wantStatus: http.StatusBadRequest,
120 | },
121 | } {
122 | t.Run(label, func(t *testing.T) {
123 | mpd := &mpdLibrary{
124 | t: t,
125 | update: tt.update,
126 | }
127 | h, err := api.NewLibraryHandler(mpd)
128 | if err != nil {
129 | t.Fatalf("api.NewLibraryHandler(mpd) = %v, %v", h, err)
130 | }
131 | defer h.Close()
132 | r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.body))
133 | w := httptest.NewRecorder()
134 | h.ServeHTTP(w, r)
135 | if status, got := w.Result().StatusCode, w.Body.String(); status != tt.wantStatus || got != tt.want {
136 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, tt.wantStatus, tt.want)
137 | }
138 | })
139 | }
140 | }
141 |
142 | type mpdLibrary struct {
143 | t *testing.T
144 | update func(*testing.T, string) (map[string]string, error)
145 | }
146 |
147 | func (m *mpdLibrary) Update(ctx context.Context, a string) (map[string]string, error) {
148 | m.t.Helper()
149 | if m.update == nil {
150 | m.t.Fatal("no Update mock function")
151 | }
152 | return m.update(m.t, a)
153 | }
154 |
--------------------------------------------------------------------------------
/internal/vv/api/library_songs_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "net/http/httptest"
9 | "reflect"
10 | "testing"
11 |
12 | "github.com/meiraka/vv/internal/vv/api"
13 | )
14 |
15 | func TestLibrarySongsHandlerGet(t *testing.T) {
16 | songsHook, randValue := testSongsHook()
17 | for label, tt := range map[string][]struct {
18 | label string
19 | listAllInfo func(*testing.T, string) ([]map[string][]string, error)
20 | err error
21 | want string
22 | cache []map[string][]string
23 | changed bool
24 | }{
25 | "ok": {{
26 | label: "empty",
27 | listAllInfo: func(t *testing.T, path string) ([]map[string][]string, error) {
28 | t.Helper()
29 | if path != "/" {
30 | t.Errorf("called mpd.ListAllInfo(ctx, %q); want mpd.ListAllInfo(ctx, %q)", path, "/")
31 | }
32 | return []map[string][]string{}, nil
33 | },
34 | want: `[]`,
35 | cache: []map[string][]string{},
36 | changed: true,
37 | }, {
38 | label: "some data",
39 | listAllInfo: func(t *testing.T, path string) ([]map[string][]string, error) {
40 | t.Helper()
41 | if path != "/" {
42 | t.Errorf("called mpd.ListAllInfo(ctx, %q); want mpd.ListAllInfo(ctx, %q)", path, "/")
43 | }
44 | return []map[string][]string{{"file": {"/foo/bar.mp3"}}}, nil
45 | },
46 | want: fmt.Sprintf(`[{"%s":["%s"],"file":["/foo/bar.mp3"]}]`, randValue, randValue),
47 | cache: []map[string][]string{{"file": {"/foo/bar.mp3"}, randValue: {randValue}}},
48 | changed: true,
49 | }, {
50 | label: "remove",
51 | listAllInfo: func(t *testing.T, path string) ([]map[string][]string, error) {
52 | t.Helper()
53 | if path != "/" {
54 | t.Errorf("called mpd.ListAllInfo(ctx, %q); want mpd.ListAllInfo(ctx, %q)", path, "/")
55 | }
56 | return []map[string][]string{}, nil
57 | },
58 | want: `[]`,
59 | cache: []map[string][]string{},
60 | changed: true,
61 | }},
62 | `error`: {{
63 | label: "prepare data",
64 | listAllInfo: func(t *testing.T, path string) ([]map[string][]string, error) {
65 | t.Helper()
66 | if path != "/" {
67 | t.Errorf("called mpd.ListAllInfo(ctx, %q); want mpd.ListAllInfo(ctx, %q)", path, "/")
68 | }
69 | return []map[string][]string{{"file": {"/foo/bar.mp3"}}}, nil
70 | },
71 | want: fmt.Sprintf(`[{"%s":["%s"],"file":["/foo/bar.mp3"]}]`, randValue, randValue),
72 | cache: []map[string][]string{{"file": {"/foo/bar.mp3"}, randValue: {randValue}}},
73 | changed: true,
74 | }, {
75 | label: "error",
76 | listAllInfo: func(t *testing.T, path string) ([]map[string][]string, error) {
77 | t.Helper()
78 | if path != "/" {
79 | t.Errorf("called mpd.ListAllInfo(ctx, %q); want mpd.ListAllInfo(ctx, %q)", path, "/")
80 | }
81 | return nil, errTest
82 | },
83 | err: errTest,
84 | want: fmt.Sprintf(`[{"%s":["%s"],"file":["/foo/bar.mp3"]}]`, randValue, randValue),
85 | cache: []map[string][]string{{"file": {"/foo/bar.mp3"}, randValue: {randValue}}},
86 | }},
87 | } {
88 | t.Run(label, func(t *testing.T) {
89 | mpd := &mpdLibrarySongs{t: t}
90 | h, err := api.NewLibrarySongsHandler(mpd, songsHook)
91 | if err != nil {
92 | t.Fatalf("api.NewLibrarySongs() = %v, %v", h, err)
93 | }
94 | for i := range tt {
95 | t.Run(tt[i].label, func(t *testing.T) {
96 | mpd.listAllInfo = tt[i].listAllInfo
97 | if err := h.Update(context.TODO()); !errors.Is(err, tt[i].err) {
98 | t.Errorf("handler.Update(context.TODO()) = %v; want %v", err, tt[i].err)
99 | }
100 |
101 | r := httptest.NewRequest(http.MethodGet, "/", nil)
102 | w := httptest.NewRecorder()
103 | h.ServeHTTP(w, r)
104 | if status, got := w.Result().StatusCode, w.Body.String(); status != http.StatusOK || got != tt[i].want {
105 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, http.StatusOK, tt[i].want)
106 | }
107 | if cache := h.Cache(); !reflect.DeepEqual(cache, tt[i].cache) {
108 | t.Errorf("got cache\n%v; want\n%v", cache, tt[i].cache)
109 | }
110 | if changed := recieveMsg(h.Changed()); changed != tt[i].changed {
111 | t.Errorf("changed = %v; want %v", changed, tt[i].changed)
112 | }
113 | })
114 | }
115 | })
116 | }
117 |
118 | }
119 |
120 | func testSongsHook() (func(s []map[string][]string) []map[string][]string, string) {
121 | f, key := testSongHook()
122 | return func(s []map[string][]string) []map[string][]string {
123 | for i := range s {
124 | s[i] = f(s[i])
125 | }
126 | return s
127 | }, key
128 | }
129 |
130 | type mpdLibrarySongs struct {
131 | t *testing.T
132 | listAllInfo func(*testing.T, string) ([]map[string][]string, error)
133 | }
134 |
135 | func (m *mpdLibrarySongs) ListAllInfo(ctx context.Context, s string) ([]map[string][]string, error) {
136 | m.t.Helper()
137 | if m.listAllInfo == nil {
138 | m.t.Fatal("no ListAllInfo mock function")
139 | }
140 | return m.listAllInfo(m.t, s)
141 | }
142 |
--------------------------------------------------------------------------------
/internal/vv/api/images/cache.go:
--------------------------------------------------------------------------------
1 | package images
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "strconv"
10 | "strings"
11 | "time"
12 |
13 | bolt "go.etcd.io/bbolt"
14 | )
15 |
16 | var (
17 | bucketKeyToURL = []byte("key2url")
18 | bucketURLToLocal = []byte("url2local")
19 | bucketKeyToReqID = []byte("key2req")
20 | )
21 |
22 | type cache struct {
23 | cacheDir string
24 | db *bolt.DB
25 | }
26 |
27 | func newCache(cacheDir string) (*cache, error) {
28 | if err := os.MkdirAll(cacheDir, 0766); err != nil {
29 | return nil, err
30 | }
31 | db, err := bolt.Open(filepath.Join(cacheDir, "db4"), 0666, &bolt.Options{Timeout: time.Second})
32 | if err != nil {
33 | if errors.Is(err, bolt.ErrTimeout) {
34 | return nil, fmt.Errorf("obtain cache lock: %w", err)
35 | }
36 | return nil, err
37 | }
38 |
39 | if err := db.Update(func(tx *bolt.Tx) error {
40 | for _, s := range [][]byte{bucketKeyToURL, bucketURLToLocal, bucketKeyToReqID} {
41 | _, err := tx.CreateBucketIfNotExists(s)
42 | if err != nil {
43 | return fmt.Errorf("create bucket: %s", err)
44 | }
45 | }
46 | return nil
47 | }); err != nil {
48 | return nil, err
49 | }
50 | return &cache{
51 | cacheDir: cacheDir,
52 | db: db,
53 | }, nil
54 | }
55 |
56 | // GetLocalPath returns local file path from url path.
57 | func (c *cache) GetLocalPath(r string) (string, bool) {
58 | var localName []byte
59 | if err := c.db.View(func(tx *bolt.Tx) error {
60 | localName = tx.Bucket(bucketURLToLocal).Get([]byte(r))
61 | return nil
62 | }); err != nil {
63 | return "", false
64 | }
65 | if localName == nil {
66 | return "", false
67 | }
68 | return filepath.Join(c.cacheDir, string(localName)), true
69 | }
70 |
71 | // GetURL returns url string from song key.
72 | func (c *cache) GetURL(key string) (string, bool) {
73 | var url []byte
74 | if err := c.db.View(func(tx *bolt.Tx) error {
75 | url = tx.Bucket(bucketKeyToURL).Get([]byte(key))
76 | return nil
77 | }); err != nil {
78 | return "", false
79 | }
80 | if url == nil {
81 | return "", false
82 | }
83 | if len(url) == 0 {
84 | return "", true
85 | }
86 | return string(url), true
87 | }
88 |
89 | func (c *cache) GetLastRequestID(key string) (string, bool) {
90 | var b []byte
91 | if err := c.db.View(func(tx *bolt.Tx) error {
92 | b = tx.Bucket(bucketKeyToReqID).Get([]byte(key))
93 | return nil
94 | }); err != nil {
95 | return "", false
96 | }
97 | if b == nil {
98 | return "", false
99 | }
100 | return string(b), true
101 | }
102 |
103 | // Set updates image cache and reqid by key.
104 | func (c *cache) Set(key, reqid string, b []byte) (err error) {
105 | bkey := []byte(key)
106 | ext, err := ext(b)
107 | if err != nil {
108 | return err
109 | }
110 |
111 | // fetch old url
112 | var url []byte
113 | if err := c.db.View(func(tx *bolt.Tx) error {
114 | url = tx.Bucket(bucketKeyToURL).Get(bkey)
115 | return nil
116 | }); err != nil {
117 | return err
118 | }
119 |
120 | var filename string
121 | var version int64
122 | if len(url) != 0 {
123 | // get old version
124 | url := string(url)
125 | i := strings.LastIndex(url, "=")
126 | if i > 0 {
127 | if v, err := strconv.ParseInt(url[i+1:], 10, 64); err == nil {
128 | version = v
129 | if version == 9223372036854775807 {
130 | version = 0
131 | } else {
132 | version = version + 1
133 | }
134 | }
135 | }
136 | // update ext
137 | i = strings.LastIndex(url, ".")
138 | if i < 0 {
139 | i = len(url)
140 | }
141 | filename = url[0:i] + "." + ext
142 | }
143 |
144 | // save image to random filename.
145 | var f *os.File
146 | if len(filename) == 0 {
147 | f, err = os.CreateTemp(c.cacheDir, "*."+ext)
148 | } else {
149 | // compare to old binary
150 | path := filepath.Join(c.cacheDir, filename)
151 | if _, err := os.Stat(path); err == nil {
152 | ob, err := os.ReadFile(path)
153 | if err == nil {
154 | if bytes.Equal(b, ob) {
155 | // same binary
156 | return c.db.Update(func(tx *bolt.Tx) error {
157 | tx.Bucket(bucketKeyToReqID).Put(bkey, []byte(reqid))
158 | return nil
159 | })
160 | }
161 | }
162 | }
163 | f, err = os.Create(path)
164 | }
165 | if err != nil {
166 | return err
167 | }
168 | f.Write(b)
169 | if err := f.Close(); err != nil {
170 | return err
171 | }
172 |
173 | // stores filename to db
174 | value := filepath.Base(f.Name())
175 | return c.db.Update(func(tx *bolt.Tx) error {
176 | tx.Bucket(bucketKeyToURL).Put(bkey, []byte(value+"?v="+strconv.FormatInt(version, 10)))
177 | tx.Bucket(bucketURLToLocal).Put([]byte(value), []byte(value))
178 | tx.Bucket(bucketKeyToReqID).Put(bkey, []byte(reqid))
179 | return nil
180 | })
181 | }
182 |
183 | // Set updates image cache and reqid by key.
184 | func (c *cache) SetEmpty(key, reqid string) error {
185 | return c.db.Update(func(tx *bolt.Tx) error {
186 | bkey := []byte(key)
187 | tx.Bucket(bucketKeyToURL).Put(bkey, []byte(""))
188 | tx.Bucket(bucketKeyToReqID).Put(bkey, []byte(reqid))
189 | return nil
190 | })
191 | }
192 |
193 | // Close finalizes cache db, coroutines.
194 | func (c *cache) Close() error {
195 | return c.db.Close()
196 | }
197 |
--------------------------------------------------------------------------------
/internal/vv/api/playlist.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "net/http"
8 | "sync"
9 | "time"
10 |
11 | "github.com/meiraka/vv/internal/mpd"
12 | "github.com/meiraka/vv/internal/songs"
13 | )
14 |
15 | type httpPlaylistInfo struct {
16 | // current track
17 | Current *int `json:"current,omitempty"`
18 | // sort functions
19 | Sort []string `json:"sort,omitempty"`
20 | Filters [][2]*string `json:"filters,omitempty"`
21 | Must int `json:"must,omitempty"`
22 | }
23 |
24 | // PlaylistHandler provides current playlist sort function.
25 | type PlaylistHandler struct {
26 | mpd MPDPlaylist
27 | library []map[string][]string
28 | librarySort []map[string][]string
29 | playlist []map[string][]string
30 | cache *cache
31 | data *httpPlaylistInfo
32 | mu sync.RWMutex
33 | sem chan struct{}
34 | config *Config
35 | }
36 |
37 | type MPDPlaylist interface {
38 | Play(context.Context, int) error
39 | ExecCommandList(context.Context, *mpd.CommandList) error
40 | }
41 |
42 | func NewPlaylistHandler(mpd MPDPlaylist, config *Config) (*PlaylistHandler, error) {
43 | c, err := newCache(&httpPlaylistInfo{})
44 | if err != nil {
45 | return nil, err
46 | }
47 | sem := make(chan struct{}, 1)
48 | sem <- struct{}{}
49 | return &PlaylistHandler{
50 | mpd: mpd,
51 | cache: c,
52 | data: &httpPlaylistInfo{},
53 | sem: sem,
54 | config: config,
55 | }, nil
56 | }
57 |
58 | func (a *PlaylistHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
59 | if r.Method != "POST" {
60 | a.cache.ServeHTTP(w, r)
61 | return
62 | }
63 | var req httpPlaylistInfo
64 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
65 | writeHTTPError(w, http.StatusBadRequest, err)
66 | return
67 | }
68 |
69 | if req.Current == nil || req.Filters == nil || req.Sort == nil {
70 | writeHTTPError(w, http.StatusBadRequest, errors.New("current, filters and sort fields are required"))
71 | return
72 | }
73 |
74 | select {
75 | case <-a.sem:
76 | default:
77 | // TODO: switch to better status code
78 | writeHTTPError(w, http.StatusServiceUnavailable, errors.New("updating playlist"))
79 | return
80 | }
81 |
82 | a.mu.Lock()
83 | librarySort, filters, newpos := songs.WeakFilterSort(a.library, req.Sort, req.Filters, req.Must, 9999, *req.Current)
84 | a.librarySort = librarySort
85 | update := !songs.SortEqual(a.playlist, a.librarySort)
86 | a.mu.Unlock()
87 | cl := &mpd.CommandList{}
88 | cl.Clear()
89 | for i := range a.librarySort {
90 | cl.Add(a.librarySort[i]["file"][0])
91 | }
92 | cl.Play(newpos)
93 | if !update {
94 | defer func() { a.sem <- struct{}{} }()
95 | a.updateSort(req.Sort, filters, req.Must)
96 | a.mu.Lock()
97 | a.cache.SetIfModified(a.data)
98 | a.mu.Unlock()
99 | now := time.Now().UTC()
100 | ctx := r.Context()
101 | if err := a.mpd.Play(ctx, newpos); err != nil {
102 | writeHTTPError(w, http.StatusInternalServerError, err)
103 | return
104 | }
105 | r.Method = http.MethodGet
106 | a.cache.ServeHTTP(w, setUpdateTime(r, now))
107 | return
108 | }
109 | r.Method = http.MethodGet
110 | a.cache.ServeHTTP(w, setUpdateTime(r, time.Now().UTC()))
111 | go func() {
112 | defer func() { a.sem <- struct{}{} }()
113 | ctx, cancel := context.WithTimeout(context.Background(), a.config.BackgroundTimeout)
114 | defer cancel()
115 | if err := a.mpd.ExecCommandList(ctx, cl); err != nil {
116 | return
117 | }
118 | a.updateSort(req.Sort, filters, req.Must)
119 | }()
120 | }
121 |
122 | func (a *PlaylistHandler) UpdateCurrent(pos int) error {
123 | a.mu.Lock()
124 | defer a.mu.Unlock()
125 | data := &httpPlaylistInfo{
126 | Current: &pos,
127 | Sort: a.data.Sort,
128 | Filters: a.data.Filters,
129 | Must: a.data.Must,
130 | }
131 | _, err := a.cache.SetIfModified(data)
132 | if err != nil {
133 | return err
134 | }
135 | a.data = data
136 | return nil
137 | }
138 |
139 | func (a *PlaylistHandler) updateSort(sort []string, filters [][2]*string, must int) {
140 | a.mu.Lock()
141 | defer a.mu.Unlock()
142 | data := &httpPlaylistInfo{
143 | Current: a.data.Current,
144 | Sort: sort,
145 | Filters: filters,
146 | Must: must,
147 | }
148 | a.data = data
149 | }
150 |
151 | func (a *PlaylistHandler) UpdatePlaylistSongs(i []map[string][]string) {
152 | a.mu.Lock()
153 | a.playlist = i
154 | unsort := a.data.Sort != nil && !songs.SortEqual(a.playlist, a.librarySort)
155 | a.mu.Unlock()
156 | if unsort {
157 | a.updateSort(nil, nil, 0)
158 | a.mu.Lock()
159 | a.cache.SetIfModified(a.data)
160 | a.mu.Unlock()
161 | }
162 | }
163 |
164 | func (a *PlaylistHandler) UpdateLibrarySongs(i []map[string][]string) {
165 | a.mu.Lock()
166 | a.library = songs.Copy(i)
167 | a.librarySort = nil
168 | a.mu.Unlock()
169 | }
170 |
171 | // Changed returns library song list update event chan.
172 | func (a *PlaylistHandler) Changed() <-chan struct{} {
173 | return a.cache.Changed()
174 | }
175 |
176 | // Close closes update event chan.
177 | func (a *PlaylistHandler) Close() {
178 | a.cache.Close()
179 | }
180 |
181 | // Wait waits playlist updates.
182 | func (a *PlaylistHandler) Wait(ctx context.Context) error {
183 | select {
184 | case <-a.sem:
185 | a.sem <- struct{}{}
186 | return nil
187 | case <-ctx.Done():
188 | return ctx.Err()
189 | }
190 | }
191 |
192 | // Shutdown waits playlist updates. Shutdown does not allow no playlist updates request.
193 | func (a *PlaylistHandler) Shutdown(ctx context.Context) error {
194 | select {
195 | case <-a.sem:
196 | return nil
197 | case <-ctx.Done():
198 | return ctx.Err()
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "os"
7 | "os/signal"
8 | "path/filepath"
9 | "strings"
10 | "syscall"
11 | "time"
12 |
13 | "github.com/meiraka/vv/internal/log"
14 | "github.com/meiraka/vv/internal/mpd"
15 | "github.com/meiraka/vv/internal/vv"
16 | "github.com/meiraka/vv/internal/vv/api"
17 | "github.com/meiraka/vv/internal/vv/api/images"
18 | "github.com/meiraka/vv/internal/vv/assets"
19 | )
20 |
21 | const (
22 | defaultConfigDir = "/etc/xdg/vv"
23 | )
24 |
25 | var version = "v0.12.0+"
26 |
27 | func main() {
28 | v2()
29 | }
30 |
31 | func configDirs() []string {
32 | dir, err := os.UserConfigDir()
33 | if err != nil {
34 | return []string{defaultConfigDir}
35 | }
36 | return []string{filepath.Join(dir, "vv"), defaultConfigDir}
37 | }
38 |
39 | func v2() {
40 | ctx := context.TODO()
41 | logger := log.New(os.Stderr)
42 |
43 | lastModified := time.Now()
44 | config, configDate, err := ParseConfig(configDirs(), "config.yaml", os.Args)
45 | if err != nil {
46 | logger.Fatalf("failed to load config: %v", err)
47 | }
48 | if lastModified.Before(configDate) {
49 | lastModified = configDate
50 | }
51 | if config.debug {
52 | logger = log.NewDebugLogger(os.Stderr)
53 | }
54 | client, err := mpd.Dial(config.MPD.Network, config.MPD.Addr, &mpd.ClientOptions{
55 | BinaryLimit: int(config.MPD.BinaryLimit),
56 | Timeout: 10 * time.Second,
57 | HealthCheckInterval: time.Second,
58 | ReconnectionInterval: 5 * time.Second,
59 | CacheCommandsResult: config.Server.Cover.Remote,
60 | })
61 | if err != nil {
62 | logger.Fatalf("failed to dial mpd: %v", err)
63 | }
64 | watcher, err := mpd.NewWatcher(config.MPD.Network, config.MPD.Addr, &mpd.WatcherOptions{
65 | Timeout: 10 * time.Second,
66 | ReconnectionInterval: 5 * time.Second,
67 | })
68 | if err != nil {
69 | logger.Fatalf("failed to dial mpd: %v", err)
70 | }
71 | // get music dir from local mpd connection
72 | if config.MPD.Network == "unix" && config.MPD.MusicDirectory == "" {
73 | if c, err := client.Config(ctx); err == nil {
74 | if dir, ok := c["music_directory"]; ok && filepath.IsAbs(dir) {
75 | config.MPD.MusicDirectory = dir
76 | logger.Printf("apply mpd.music_directory from mpd connection: %s", dir)
77 | }
78 | }
79 | }
80 |
81 | // get music dir from local mpd config
82 | mpdConf, _ := mpd.ParseConfig(config.MPD.Conf)
83 | if config.MPD.MusicDirectory == "" {
84 | if mpdConf != nil && filepath.IsAbs(config.MPD.Conf) {
85 | config.MPD.MusicDirectory = mpdConf.MusicDirectory
86 | logger.Printf("apply mpd.music_directory from %s: %s", config.MPD.Conf, mpdConf.MusicDirectory)
87 | }
88 | }
89 | proxy := map[string]string{}
90 | if mpdConf != nil {
91 | host := "localhost"
92 | if config.MPD.Network == "tcp" {
93 | h := strings.Split(config.MPD.Addr, ":")[0]
94 | if len(h) != 0 {
95 | host = h
96 | }
97 | }
98 | for _, dev := range mpdConf.AudioOutputs {
99 | if len(dev.Port) != 0 {
100 | proxy[dev.Name] = "http://" + host + ":" + dev.Port
101 | }
102 | }
103 | }
104 | m := http.NewServeMux()
105 | covers := make([]api.ImageProvider, 0, 2)
106 | if config.Server.Cover.Local {
107 | if len(config.MPD.MusicDirectory) == 0 {
108 | logger.Println("config.server.cover.local is disabled: mpd.music_directory is empty")
109 | } else {
110 | c, err := images.NewLocal("/api/music/images/local/", config.MPD.MusicDirectory, []string{"cover.jpg", "cover.jpeg", "cover.webp", "cover.png", "cover.gif", "cover.bmp"})
111 | if err != nil {
112 | logger.Fatalf("failed to initialize coverart: %v", err)
113 | }
114 | m.Handle("/api/music/images/local/", c)
115 | covers = append(covers, c)
116 |
117 | }
118 | }
119 | if config.Server.Cover.Remote {
120 | a, err := images.NewRemote("/api/music/images/albumart/", client, filepath.Join(config.Server.CacheDirectory, "albumart"))
121 | if err != nil {
122 | logger.Fatalf("failed to initialize coverart: %v", err)
123 | }
124 | m.Handle("/api/music/images/albumart/", a)
125 | covers = append(covers, a)
126 | defer a.Close()
127 | e, err := images.NewEmbed("/api/music/images/embed/", client, filepath.Join(config.Server.CacheDirectory, "embed"))
128 | if err != nil {
129 | logger.Fatalf("failed to initialize coverart: %v", err)
130 | }
131 | m.Handle("/api/music/images/embed/", e)
132 | covers = append(covers, e)
133 | defer e.Close()
134 | }
135 | root, err := vv.New(&vv.Config{
136 | Tree: toTree(config.Playlist.Tree),
137 | TreeOrder: config.Playlist.TreeOrder,
138 | Local: config.debug,
139 | LastModified: lastModified,
140 | Logger: logger,
141 | })
142 | if err != nil {
143 | logger.Fatalf("failed to initialize root handler: %v", err)
144 | }
145 | assets, err := assets.NewHandler(&assets.Config{
146 | Local: config.debug,
147 | LastModified: lastModified,
148 | Logger: logger,
149 | })
150 | if err != nil {
151 | logger.Fatalf("failed to initialize assets handler: %v", err)
152 | }
153 | api, err := api.NewHandler(ctx, client, watcher, &api.Config{
154 | AppVersion: version,
155 | AudioProxy: proxy,
156 | ImageProviders: covers,
157 | Logger: logger,
158 | })
159 | if err != nil {
160 | logger.Fatalf("failed to initialize api handler: %v", err)
161 | }
162 | m.Handle("/", root)
163 | m.Handle("/assets/", assets)
164 | m.Handle("/api/", api)
165 |
166 | s := http.Server{
167 | Handler: m,
168 | Addr: config.Server.Addr,
169 | }
170 | s.RegisterOnShutdown(api.Stop)
171 | errs := make(chan error, 1)
172 | go func() {
173 | errs <- s.ListenAndServe()
174 | }()
175 | sc := make(chan os.Signal, 1)
176 | signal.Notify(sc, syscall.SIGTERM, syscall.SIGINT)
177 | select {
178 | case <-sc:
179 | case err := <-errs:
180 | if err != http.ErrServerClosed {
181 | logger.Fatalf("server stopped with error: %v", err)
182 | }
183 | }
184 |
185 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
186 | defer cancel()
187 | if err := s.Shutdown(ctx); err != nil {
188 | logger.Printf("failed to stop http server: %v", err)
189 | }
190 | if err := client.Close(ctx); err != nil {
191 | logger.Printf("failed to close mpd connection(main): %v", err)
192 | }
193 | if err := watcher.Close(ctx); err != nil {
194 | logger.Printf("failed to close mpd connection(event): %v", err)
195 | }
196 | if err := api.Shutdown(ctx); err != nil {
197 | logger.Printf("failed to stop api background task: %v", err)
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/internal/vv/api/playlist_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "context"
5 | "io"
6 | "net/http"
7 | "net/http/httptest"
8 | "strings"
9 | "testing"
10 | "time"
11 |
12 | "github.com/meiraka/vv/internal/mpd"
13 | "github.com/meiraka/vv/internal/songs"
14 | "github.com/meiraka/vv/internal/vv/api"
15 | )
16 |
17 | var testSongs = []map[string][]string{
18 | {"file": {"/foo/bar.mp3"}, "Title": {"bar"}, "Album": {"foo"}},
19 | {"file": {"/foo/foo.mp3"}, "Title": {"foo"}, "Album": {"foo"}},
20 | {"file": {"/baz/qux.mp3"}, "Title": {"qux"}, "Album": {"baz"}},
21 | {"file": {"/baz/baz.mp3"}, "Title": {"baz"}, "Album": {"baz"}},
22 | }
23 |
24 | func TestPlaylistHandler(t *testing.T) {
25 | for label, tt := range map[string][]struct {
26 | label string
27 | method string
28 | body io.Reader
29 | library []map[string][]string
30 | playlist []map[string][]string
31 | pos *int
32 | want string
33 | wantStatus int
34 | mpd *mpdPlaylist
35 | }{
36 | "ok/GET": {{
37 | method: http.MethodGet,
38 | want: `{}`,
39 | wantStatus: http.StatusOK,
40 | }},
41 | "error/POST/invalid json": {{
42 | method: http.MethodPost,
43 | body: strings.NewReader(`invalid json`),
44 | want: `{"error":"invalid character 'i' looking for beginning of value"}`,
45 | wantStatus: http.StatusBadRequest,
46 | }},
47 | "ok/sort": {{
48 | label: `POST/{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`,
49 | library: songs.Copy(testSongs),
50 | method: http.MethodPost,
51 | body: strings.NewReader(`{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`),
52 | want: `{}`,
53 | wantStatus: http.StatusAccepted,
54 | mpd: &mpdPlaylist{
55 | execCommandList: func(t *testing.T, got *mpd.CommandList) error {
56 | want := &mpd.CommandList{}
57 | want.Clear()
58 | want.Add("/baz/baz.mp3")
59 | want.Add("/baz/qux.mp3")
60 | want.Add("/foo/bar.mp3")
61 | want.Add("/foo/foo.mp3")
62 | want.Play(1)
63 | t.Helper()
64 | if !mpd.CommandListEqual(got, want) {
65 | t.Errorf("call mpd.ExecCommandList(ctx,\n%v); want mpd.ExecCommandList(ctx,\n%v)", got, want)
66 | }
67 | return nil
68 | },
69 | },
70 | }, {
71 | label: `GET`,
72 | playlist: []map[string][]string{testSongs[3], testSongs[2], testSongs[0], testSongs[1]},
73 | pos: intptr(1),
74 | method: http.MethodGet,
75 | want: `{"current":1,"sort":["Album","Title"]}`,
76 | wantStatus: http.StatusOK,
77 | }, {
78 | label: `GET/playlist changed`,
79 | playlist: []map[string][]string{testSongs[3], testSongs[2], testSongs[1], testSongs[0]},
80 | pos: intptr(1),
81 | method: http.MethodGet,
82 | want: `{"current":1}`,
83 | wantStatus: http.StatusOK,
84 | }},
85 | "error/sort": {{
86 | label: `POST/{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`,
87 | library: songs.Copy(testSongs),
88 | method: http.MethodPost,
89 | body: strings.NewReader(`{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`),
90 | want: `{}`,
91 | wantStatus: http.StatusAccepted,
92 | mpd: &mpdPlaylist{
93 | execCommandList: func(t *testing.T, got *mpd.CommandList) error {
94 | want := &mpd.CommandList{}
95 | want.Clear()
96 | want.Add("/baz/baz.mp3")
97 | want.Add("/baz/qux.mp3")
98 | want.Add("/foo/bar.mp3")
99 | want.Add("/foo/foo.mp3")
100 | want.Play(1)
101 | t.Helper()
102 | if !mpd.CommandListEqual(got, want) {
103 | t.Errorf("call mpd.ExecCommandList(ctx,\n%v); want mpd.ExecCommandList(ctx,\n%v)", got, want)
104 | }
105 | return errTest
106 | },
107 | },
108 | }, {
109 | label: `GET`,
110 | playlist: []map[string][]string{testSongs[3], testSongs[2], testSongs[0], testSongs[1]},
111 | pos: intptr(1),
112 | method: http.MethodGet,
113 | want: `{"current":1}`, // sort is not updated
114 | wantStatus: http.StatusOK,
115 | }},
116 | "ok/track": {{
117 | label: `POST/{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`,
118 | library: songs.Copy(testSongs),
119 | playlist: []map[string][]string{testSongs[3], testSongs[2], testSongs[0], testSongs[1]},
120 | method: http.MethodPost,
121 | body: strings.NewReader(`{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`),
122 | want: `{"sort":["Album","Title"]}`,
123 | wantStatus: http.StatusAccepted,
124 | mpd: &mpdPlaylist{play: mockIntFunc("mpd.Play(ctx, %d)", 1, nil)},
125 | }, {
126 | label: `GET`,
127 | pos: intptr(1),
128 | method: http.MethodGet,
129 | want: `{"current":1,"sort":["Album","Title"]}`,
130 | wantStatus: http.StatusOK,
131 | }},
132 | "error/track": {{
133 | label: `POST/{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`,
134 | library: songs.Copy(testSongs),
135 | playlist: []map[string][]string{testSongs[3], testSongs[2], testSongs[0], testSongs[1]},
136 | method: http.MethodPost,
137 | body: strings.NewReader(`{"current":1,"filters":[["Album","baz"],["Title","qux"]],"sort":["Album","Title"]}`),
138 | want: `{"error":"api_test: test error"}`,
139 | wantStatus: http.StatusInternalServerError,
140 | mpd: &mpdPlaylist{play: mockIntFunc("mpd.Play(ctx, %d)", 1, errTest)},
141 | }},
142 | } {
143 | t.Run(label, func(t *testing.T) {
144 | mpd := &mpdPlaylist{t: t}
145 | h, err := api.NewPlaylistHandler(mpd, &api.Config{BackgroundTimeout: time.Second})
146 | if err != nil {
147 | t.Fatalf("api.NewPlaylistHandler(mpd, config) = %v", err)
148 | }
149 | defer h.Close()
150 | for i := range tt {
151 | f := func(t *testing.T) {
152 | mpd.t = t
153 | if tt[i].mpd != nil {
154 | mpd.play = tt[i].mpd.play
155 | mpd.execCommandList = tt[i].mpd.execCommandList
156 | }
157 | if tt[i].library != nil {
158 | h.UpdateLibrarySongs(tt[i].library)
159 | }
160 | if tt[i].playlist != nil {
161 | h.UpdatePlaylistSongs(tt[i].playlist)
162 | }
163 | if tt[i].pos != nil {
164 | h.UpdateCurrent(*tt[i].pos)
165 | }
166 |
167 | r := httptest.NewRequest(tt[i].method, "/", tt[i].body)
168 | w := httptest.NewRecorder()
169 | h.ServeHTTP(w, r)
170 | if status, got := w.Result().StatusCode, w.Body.String(); status != tt[i].wantStatus || got != tt[i].want {
171 | t.Errorf("ServeHTTP got\n%d %s; want\n%d %s", status, got, tt[i].wantStatus, tt[i].want)
172 | }
173 | h.Wait(context.TODO())
174 | }
175 | if len(tt) != 1 {
176 | t.Run(tt[i].label, f)
177 | } else {
178 | f(t)
179 | }
180 | }
181 | })
182 | }
183 | }
184 |
185 | type mpdPlaylist struct {
186 | t *testing.T
187 | play func(*testing.T, int) error
188 | execCommandList func(*testing.T, *mpd.CommandList) error
189 | }
190 |
191 | func (m *mpdPlaylist) Play(ctx context.Context, i int) error {
192 | m.t.Helper()
193 | if m.play == nil {
194 | m.t.Fatal("no Play mock function")
195 | }
196 | return m.play(m.t, i)
197 | }
198 | func (m *mpdPlaylist) ExecCommandList(ctx context.Context, i *mpd.CommandList) error {
199 | m.t.Helper()
200 | if m.execCommandList == nil {
201 | m.t.Fatal("no ExecCommandList mock function")
202 | }
203 | return m.execCommandList(m.t, i)
204 | }
205 |
--------------------------------------------------------------------------------
/internal/vv/assets/handler_test.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import (
4 | "bytes"
5 | "crypto/md5"
6 | "encoding/hex"
7 | "fmt"
8 | "net/http"
9 | "net/http/httptest"
10 | "os"
11 | "reflect"
12 | "testing"
13 | "time"
14 |
15 | "github.com/meiraka/vv/internal/gzip"
16 | )
17 |
18 | func TestLocalHandler(t *testing.T) {
19 | assets := []string{"/assets/app-black.png", "/assets/app-black.svg", "/assets/app.css", "/assets/app.js", "/assets/app.png", "/assets/app.svg", "/assets/manifest.json", "/assets/nocover.svg", "/assets/w.png"}
20 | for _, conf := range []*Config{nil, {}, {Local: true, LocalDir: "."}} {
21 | t.Run(fmt.Sprintf("%+v", conf), func(t *testing.T) {
22 | h, err := NewHandler(conf)
23 | if err != nil {
24 | t.Fatalf("failed to init hander: %v", err)
25 | }
26 | for _, path := range assets {
27 | t.Run(fmt.Sprint(path), func(t *testing.T) {
28 | req := httptest.NewRequest(http.MethodGet, path, nil)
29 | w := httptest.NewRecorder()
30 | h.ServeHTTP(w, req)
31 | resp := w.Result()
32 | if resp.StatusCode != 200 {
33 | t.Errorf("got %d; want %d", resp.StatusCode, 200)
34 | }
35 |
36 | })
37 | }
38 | })
39 | }
40 |
41 | }
42 |
43 | func TestHandler(t *testing.T) {
44 | now := time.Now()
45 | testsets := map[string]struct {
46 | prehook func(tb testing.TB)
47 | handler http.Handler
48 | reqPath string
49 | reqHeader http.Header
50 |
51 | resStatus int
52 | resHeader http.Header
53 | resBody []byte
54 | }{
55 | "embed/text/no-gzip": {
56 | handler: must(NewHandler(&Config{LastModified: now})),
57 | reqPath: "/assets/app.svg",
58 | reqHeader: http.Header{"Accept-Encoding": {"identity"}}, resStatus: http.StatusOK,
59 | resHeader: http.Header{
60 | "Cache-Control": {"max-age=86400"},
61 | "Content-Type": {"image/svg+xml"},
62 | "Etag": {`"` + calcMD5File(t, "app.svg") + `"`},
63 | "Last-Modified": {now.UTC().Format(http.TimeFormat)},
64 | "Transfer-Encoding": nil,
65 | "Vary": {"Accept-Encoding"},
66 | },
67 | resBody: readFile(t, "app.svg")},
68 | "embed/text/gzip": {
69 | handler: must(NewHandler(&Config{LastModified: now})),
70 | reqPath: "/assets/app.svg",
71 | reqHeader: http.Header{"Accept-Encoding": {"gzip"}}, resStatus: http.StatusOK,
72 | resHeader: http.Header{
73 | "Cache-Control": {"max-age=86400"},
74 | "Content-Type": {"image/svg+xml"},
75 | "Etag": {`"` + calcGZMD5File(t, "app.svg") + `"`},
76 | "Last-Modified": {now.UTC().Format(http.TimeFormat)},
77 | "Transfer-Encoding": nil,
78 | "Vary": {"Accept-Encoding"},
79 | },
80 | resBody: gz(t, readFile(t, "app.svg"))},
81 | "embed/text/gzip with param h": {
82 | handler: must(NewHandler(&Config{LastModified: now})),
83 | reqPath: "/assets/app.svg?h=0",
84 | reqHeader: http.Header{"Accept-Encoding": {"gzip"}}, resStatus: http.StatusOK,
85 | resHeader: http.Header{
86 | "Cache-Control": {"max-age=31536000"},
87 | "Content-Type": {"image/svg+xml"},
88 | "Etag": {`"` + calcGZMD5File(t, "app.svg") + `"`},
89 | "Last-Modified": {now.UTC().Format(http.TimeFormat)},
90 | "Transfer-Encoding": nil,
91 | "Vary": {"Accept-Encoding"},
92 | },
93 | resBody: gz(t, readFile(t, "app.svg"))},
94 | "embed/if none match": {
95 | handler: must(NewHandler(&Config{LastModified: now})),
96 | reqPath: "/assets/app.svg",
97 | reqHeader: http.Header{"If-None-Match": {`"` + calcMD5File(t, "app.svg") + `"`}}, resStatus: http.StatusNotModified,
98 | resHeader: http.Header{
99 | "Cache-Control": nil,
100 | "Content-Type": nil,
101 | "Etag": nil,
102 | "Last-Modified": nil,
103 | "Transfer-Encoding": nil,
104 | "Vary": nil,
105 | },
106 | resBody: []byte("")},
107 | "embed/gzip if none match": {
108 | handler: must(NewHandler(&Config{LastModified: now})),
109 | reqPath: "/assets/app.svg",
110 | reqHeader: http.Header{"Accept-Encoding": {"gzip"}, "If-None-Match": {`"` + calcGZMD5File(t, "app.svg") + `"`}}, resStatus: http.StatusNotModified,
111 | resHeader: http.Header{
112 | "Cache-Control": nil,
113 | "Content-Type": nil,
114 | "Etag": nil,
115 | "Last-Modified": nil,
116 | "Transfer-Encoding": nil,
117 | "Vary": nil,
118 | },
119 | resBody: []byte("")},
120 | "embed/binary no-gzip": {
121 | handler: must(NewHandler(&Config{LastModified: now})),
122 | reqPath: "/assets/app.png",
123 | reqHeader: http.Header{"Accept-Encoding": {"identity"}}, resStatus: http.StatusOK,
124 | resHeader: http.Header{
125 | "Cache-Control": {"max-age=86400"},
126 | "Content-Type": {"image/png"},
127 | "Etag": {`"` + calcMD5File(t, "app.png") + `"`},
128 | "Last-Modified": {now.UTC().Format(http.TimeFormat)},
129 | "Transfer-Encoding": nil,
130 | "Vary": nil,
131 | },
132 | resBody: readFile(t, "app.png")},
133 | "embed/binary gzip": {
134 | handler: must(NewHandler(&Config{LastModified: now})),
135 | reqPath: "/assets/app.png",
136 | reqHeader: http.Header{"Accept-Encoding": {"gzip"}}, resStatus: http.StatusOK,
137 | resHeader: http.Header{
138 | "Cache-Control": {"max-age=86400"},
139 | "Content-Type": {"image/png"},
140 | "Etag": {`"` + calcMD5File(t, "app.png") + `"`},
141 | "Last-Modified": {now.UTC().Format(http.TimeFormat)},
142 | "Transfer-Encoding": nil,
143 | "Vary": nil,
144 | },
145 | resBody: readFile(t, "app.png")},
146 | }
147 | for k, tt := range testsets {
148 | t.Run(k, func(t *testing.T) {
149 | if tt.prehook != nil {
150 | tt.prehook(t)
151 | }
152 | r := httptest.NewRequest("GET", "http://vv.local"+tt.reqPath, nil)
153 | for k, v := range tt.reqHeader {
154 | for i := range v {
155 | r.Header.Add(k, v[i])
156 | }
157 | }
158 | t.Log(r)
159 | w := httptest.NewRecorder()
160 | tt.handler.ServeHTTP(w, r)
161 | resp := w.Result()
162 | if got, want := w.Body.Bytes(), tt.resBody; !bytes.Equal(got, want) {
163 | t.Errorf("got body\n%s; want\n%s", got, want)
164 | }
165 | for k, v := range tt.resHeader {
166 | if !reflect.DeepEqual(resp.Header[k], v) {
167 | t.Errorf("got header %s=%v; want %v", k, resp.Header[k], v)
168 | }
169 | }
170 | })
171 | }
172 | }
173 |
174 | func must(t http.Handler, err error) http.Handler {
175 | if err != nil {
176 | panic(err)
177 | }
178 | return t
179 | }
180 |
181 | func gz(t *testing.T, b []byte) []byte {
182 | gz, err := gzip.Encode(b)
183 | if err != nil {
184 | t.Fatalf("failed to make gzip")
185 | }
186 | return gz
187 | }
188 |
189 | func calcGZMD5File(tb testing.TB, path string) string {
190 | b, err := os.ReadFile(path)
191 | if err != nil {
192 | tb.Fatalf("embed: readfile: %s: %v", path, err)
193 | }
194 | gz, err := gzip.Encode(b)
195 | if err != nil {
196 | tb.Fatalf("gzip: %s: %v", path, err)
197 | }
198 | hasher := md5.New()
199 | hasher.Write(gz)
200 | return hex.EncodeToString(hasher.Sum(nil))
201 | }
202 |
203 | func calcMD5File(tb testing.TB, path string) string {
204 | b, err := os.ReadFile(path)
205 | if err != nil {
206 | tb.Fatalf("embed: readfile: %s: %v", path, err)
207 | }
208 | hasher := md5.New()
209 | hasher.Write(b)
210 | return hex.EncodeToString(hasher.Sum(nil))
211 | }
212 |
213 | func readFile(tb testing.TB, path string) []byte {
214 | b, err := os.ReadFile(path)
215 | if err != nil {
216 | tb.Fatalf("embed: readfile: %s: %v", path, err)
217 | }
218 | return b
219 | }
220 |
--------------------------------------------------------------------------------
/internal/vv/handler.go:
--------------------------------------------------------------------------------
1 | package vv
2 |
3 | import (
4 | "bytes"
5 | "crypto/md5"
6 | "encoding/hex"
7 | "encoding/json"
8 | "errors"
9 | "fmt"
10 | "html/template"
11 | "io"
12 | "net/http"
13 | "os"
14 | "path/filepath"
15 | "strconv"
16 | "strings"
17 | "time"
18 |
19 | "github.com/meiraka/vv/internal/gzip"
20 | "github.com/meiraka/vv/internal/log"
21 | "github.com/meiraka/vv/internal/request"
22 | "github.com/meiraka/vv/internal/vv/assets"
23 | "golang.org/x/text/language"
24 | )
25 |
26 | // Config represents options for root page generator.
27 | type Config struct {
28 | Local bool // use local index.html(default: false)
29 | LocalDir string // path to local index.html directory(default: filepath.Join("internal", "vv"))
30 | LastModified time.Time // Last-Modified value(default: time.Now())
31 | Tree Tree // playlist view definition(default: DefaultTree)
32 | TreeOrder []string // order of playlist tree(default: DefaultTreeOrder)
33 | Data []byte // index.html data(default: embed index.html)
34 | Logger interface {
35 | Debugf(format string, v ...interface{})
36 | }
37 | }
38 |
39 | // Handler serves app root page.
40 | type Handler struct {
41 | conf *Config
42 | hashData *dataHash
43 | configData *dataConfig
44 | lastModified string
45 | plainBody [][]byte
46 | plainLength []string
47 | plainEtag []string
48 | gzBody [][]byte
49 | gzLength []string
50 | gzEtag []string
51 | }
52 |
53 | // New creates http.Handler for app root page.
54 | func New(c *Config) (*Handler, error) {
55 | conf := &Config{}
56 | if c != nil {
57 | *conf = *c
58 | }
59 | if conf.LocalDir == "" {
60 | conf.LocalDir = filepath.Join("internal", "vv")
61 | }
62 | if conf.LastModified.IsZero() {
63 | conf.LastModified = time.Now()
64 | }
65 | conf.LastModified = conf.LastModified.UTC()
66 | if conf.Tree == nil && conf.TreeOrder == nil {
67 | conf.Tree = DefaultTree
68 | conf.TreeOrder = DefaultTreeOrder
69 | }
70 | if conf.Tree == nil && conf.TreeOrder != nil {
71 | return nil, errors.New("vv: invalid config: no tree")
72 | }
73 | if conf.Tree != nil && conf.TreeOrder == nil {
74 | return nil, errors.New("vv: invalid config: no tree order")
75 | }
76 | if conf.Data == nil {
77 | conf.Data = indexHTML
78 | }
79 | if conf.Logger == nil {
80 | conf.Logger = log.New(io.Discard)
81 | }
82 | // setup handler
83 | hashData, err := newHashData()
84 | if err != nil {
85 | return nil, err
86 | }
87 | configData, err := newConfigData(&conf.Tree, conf.TreeOrder)
88 | if err != nil {
89 | return nil, err
90 | }
91 | langs := len(langPrio)
92 | h := &Handler{
93 | conf: conf,
94 | hashData: hashData,
95 | configData: configData,
96 | lastModified: conf.LastModified.UTC().Format(http.TimeFormat),
97 | plainBody: make([][]byte, langs),
98 | plainLength: make([]string, langs),
99 | plainEtag: make([]string, langs),
100 | gzBody: make([][]byte, langs),
101 | gzLength: make([]string, langs),
102 | gzEtag: make([]string, langs),
103 | }
104 | for i, lang := range langPrio {
105 | b, err := h.generate(conf.Data, lang, false)
106 | if err != nil {
107 | return nil, err
108 | }
109 | h.plainBody[i] = b
110 | h.plainLength[i] = strconv.Itoa(len(b))
111 | h.plainEtag[i] = etag2(b)
112 | gz, err := gzip.Encode(b)
113 | if err != nil {
114 | return nil, err
115 | }
116 | h.gzBody[i] = gz
117 | h.gzLength[i] = strconv.Itoa(len(gz))
118 | h.gzEtag[i] = etag2(gz)
119 | }
120 | return h, nil
121 | }
122 |
123 | func etag2(b []byte) string {
124 | hasher := md5.New()
125 | hasher.Write(b)
126 | return `"` + hex.EncodeToString(hasher.Sum(nil)) + `"`
127 | }
128 |
129 | func (h *Handler) serveHTTPLocal(w http.ResponseWriter, r *http.Request) error {
130 | tag, _ := determineLang(r)
131 | localPath := filepath.Join(h.conf.LocalDir, "index.html")
132 | lastModified := h.conf.LastModified.UTC()
133 | s, err := os.Stat(localPath)
134 | if err != nil {
135 | return fmt.Errorf("stat: %s: %v", localPath, err)
136 | }
137 | if modtime := s.ModTime().UTC(); modtime.After(lastModified) {
138 | lastModified = modtime
139 | }
140 | if !request.ModifiedSince(r, lastModified) {
141 | w.WriteHeader(304)
142 | return nil
143 | }
144 | src, err := os.ReadFile(localPath)
145 | if err != nil {
146 | return fmt.Errorf("read file: %s: %v", localPath, err)
147 | }
148 | b, err := h.generate(src, tag, true)
149 | if err != nil {
150 | return fmt.Errorf("generate: %s: %v", localPath, err)
151 | }
152 | w.Header().Add("Content-Language", tag.String())
153 | w.Header().Add("Content-Type", "text/html; charset=utf-8")
154 | w.Header().Add("Vary", "Accept-Language")
155 | w.Header().Add("Cache-Control", "max-age=1")
156 | w.Header().Add("Content-Length", strconv.Itoa(len(b)))
157 | w.Header().Add("Last-Modified", lastModified.Format(http.TimeFormat))
158 | w.Write(b)
159 | return nil
160 | }
161 |
162 | // ServeHTTP serves root page.
163 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
164 | if h.conf.Local {
165 | err := h.serveHTTPLocal(w, r)
166 | if err == nil {
167 | return
168 | }
169 | h.conf.Logger.Debugf("vv: %v", err)
170 | }
171 |
172 | tag, index := determineLang(r)
173 | gzBody := h.gzBody[index]
174 | useGZ := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && gzBody != nil
175 | var etag string
176 | if useGZ {
177 | etag = h.gzEtag[index]
178 | } else {
179 | etag = h.plainEtag[index]
180 | }
181 | if request.NoneMatch(r, etag) {
182 | w.WriteHeader(http.StatusNotModified)
183 | return
184 | }
185 | w.Header().Add("Cache-Control", "max-age=86400")
186 | w.Header().Add("Content-Language", tag.String())
187 | w.Header().Add("Content-Type", "text/html; charset=utf-8")
188 | w.Header().Add("Etag", etag)
189 | w.Header().Add("Last-Modified", h.lastModified)
190 | w.Header().Add("Vary", "Accept-Encoding, Accept-Language")
191 | if useGZ {
192 | w.Header().Add("Content-Encoding", "gzip")
193 | w.Header().Add("Content-Length", h.gzLength[index])
194 | w.Write(gzBody)
195 | return
196 | }
197 | w.Header().Add("Content-Length", h.plainLength[index])
198 | w.Write(h.plainBody[index])
199 | }
200 |
201 | func (h *Handler) generate(b []byte, lang language.Tag, local bool) ([]byte, error) {
202 | data := &data{
203 | Hash: h.hashData,
204 | Config: h.configData,
205 | Message: langData[lang],
206 | }
207 | if local {
208 | data.Hash = &dataHash{}
209 | }
210 | t, err := template.New("index.html").Parse(string(b))
211 | if err != nil {
212 | return nil, err
213 | }
214 | buf := new(bytes.Buffer)
215 | if err := t.Execute(buf, data); err != nil {
216 | return nil, err
217 | }
218 | return buf.Bytes(), nil
219 | }
220 |
221 | type data struct {
222 | Hash *dataHash
223 | Config *dataConfig
224 | Message map[string]string
225 | }
226 |
227 | type dataHash struct {
228 | AppJS string
229 | AppCSS string
230 | }
231 |
232 | func newHashData() (*dataHash, error) {
233 | css, ok := assets.Hash("/assets/app.css")
234 | if !ok {
235 | return nil, errors.New("no app.css in assets")
236 | }
237 | js, ok := assets.Hash("/assets/app.js")
238 | if !ok {
239 | return nil, errors.New("no app.js in assets")
240 | }
241 | return &dataHash{AppJS: js, AppCSS: css}, nil
242 | }
243 |
244 | type dataConfig struct {
245 | Tree string
246 | TreeOrder string
247 | }
248 |
249 | func newConfigData(tree *Tree, treeOrder []string) (*dataConfig, error) {
250 | jsonTree, err := json.Marshal(tree)
251 | if err != nil {
252 | return nil, fmt.Errorf("tree: %v", err)
253 | }
254 | jsonTreeOrder, err := json.Marshal(treeOrder)
255 | if err != nil {
256 | return nil, fmt.Errorf("tree order: %v", err)
257 | }
258 | return &dataConfig{Tree: string(jsonTree), TreeOrder: string(jsonTreeOrder)}, nil
259 | }
260 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "strconv"
10 | "strings"
11 | "time"
12 |
13 | "github.com/meiraka/vv/internal/vv"
14 | "github.com/spf13/pflag"
15 | "gopkg.in/yaml.v2"
16 | )
17 |
18 | // Config is vv application config struct.
19 | type Config struct {
20 | MPD struct {
21 | Network string `yaml:"network"`
22 | Addr string `yaml:"addr"`
23 | MusicDirectory string `yaml:"music_directory"`
24 | Conf string `yaml:"conf"`
25 | BinaryLimit BinarySize `yaml:"binarylimit"`
26 | } `yaml:"mpd"`
27 | Server struct {
28 | Addr string `yaml:"addr"`
29 | CacheDirectory string `yaml:"cache_directory"`
30 | Cover struct {
31 | Local bool `yaml:"local"`
32 | Remote bool `yaml:"remote"`
33 | } `yaml:"cover"`
34 | } `yaml:"server"`
35 | Playlist struct {
36 | Tree map[string]*ConfigListNode `yaml:"tree"`
37 | TreeOrder []string `yaml:"tree_order"`
38 | }
39 | debug bool
40 | }
41 |
42 | func DefaultConfig() *Config {
43 | c := &Config{}
44 | c.MPD.Conf = "/etc/mpd.conf"
45 | c.Server.Addr = ":8080"
46 | c.Server.CacheDirectory = filepath.Join(os.TempDir(), "vv")
47 | c.Server.Cover.Local = true
48 | return c
49 | }
50 |
51 | func fillConfig(c *Config) {
52 | if c.MPD.Network == "" {
53 | if strings.HasPrefix(c.MPD.Addr, "/") || strings.HasPrefix(c.MPD.Addr, "@") {
54 | c.MPD.Network = "unix"
55 | } else {
56 | c.MPD.Network = "tcp"
57 | }
58 | }
59 | if c.MPD.Addr == "" {
60 | switch c.MPD.Network {
61 | case "tcp", "tcp4", "tcp6":
62 | c.MPD.Addr = "localhost:6600"
63 | case "unix":
64 | c.MPD.Addr = "/var/run/mpd/socket"
65 | }
66 | }
67 | }
68 |
69 | // ParseConfig parse yaml config and flags.
70 | func ParseConfig(dir []string, name string, args []string) (*Config, time.Time, error) {
71 | c := DefaultConfig()
72 | date := time.Time{}
73 | for _, d := range dir {
74 | path := filepath.Join(d, name)
75 | _, err := os.Stat(path)
76 | if err == nil {
77 | f, err := os.Open(path)
78 | if err != nil {
79 | return nil, date, err
80 | }
81 | s, err := f.Stat()
82 | if err != nil {
83 | return nil, date, err
84 | }
85 | date = s.ModTime()
86 | defer f.Close()
87 | if err := yaml.NewDecoder(f).Decode(&c); err != nil {
88 | return nil, date, err
89 | }
90 | }
91 | }
92 | flagset := pflag.NewFlagSet(filepath.Base(args[0]), pflag.ExitOnError)
93 | mn := flagset.String("mpd.network", "", "mpd server network to connect")
94 | ma := flagset.String("mpd.addr", "", "mpd server address to connect")
95 | mm := flagset.String("mpd.music_directory", "", "set music_directory in mpd.conf value to search album cover image")
96 | mc := flagset.String("mpd.conf", "", "set mpd.conf path to get music_directory and http audio output")
97 | mb := flagset.String("mpd.binarylimit", "", "set the maximum binary response size of mpd")
98 | sa := flagset.String("server.addr", "", "this app serving address")
99 | si := flagset.Bool("server.cover.remote", false, "enable coverart via mpd api")
100 | d := flagset.BoolP("debug", "d", false, "use local assets if exists")
101 | flagset.Parse(args)
102 | if len(*mn) != 0 {
103 | c.MPD.Network = *mn
104 | }
105 | if len(*ma) != 0 {
106 | c.MPD.Addr = *ma
107 | }
108 | if len(*mm) != 0 {
109 | c.MPD.MusicDirectory = *mm
110 | }
111 | if len(*mc) != 0 {
112 | c.MPD.Conf = *mc
113 | }
114 | if len(*mb) != 0 {
115 | var bl BinarySize
116 | if err := bl.UnmarshalText([]byte(*mb)); err != nil {
117 | return nil, date, err
118 | }
119 | c.MPD.BinaryLimit = bl
120 | }
121 | if len(*sa) != 0 {
122 | c.Server.Addr = *sa
123 | }
124 | if *si {
125 | c.Server.Cover.Remote = true
126 | }
127 | c.debug = *d
128 | fillConfig(c)
129 | return c, date, nil
130 | }
131 |
132 | // Validate validates config data.
133 | func (c *Config) Validate() error {
134 | set := make(map[string]struct{}, len(c.Playlist.TreeOrder))
135 | for _, label := range c.Playlist.TreeOrder {
136 | if _, ok := set[label]; ok {
137 | return fmt.Errorf("playlist.tree_order %s is duplicated", label)
138 | }
139 | set[label] = struct{}{}
140 | node, ok := c.Playlist.Tree[label]
141 | if !ok {
142 | return fmt.Errorf("playlist.tree.%s is not defined in playlist.tree", label)
143 | }
144 | if err := node.Validate(); err != nil {
145 | return fmt.Errorf("playlist.tree.%s: %w", label, err)
146 | }
147 | }
148 | for i, label := range c.Playlist.TreeOrder {
149 | // check playlist sort is uniq
150 | if i != len(c.Playlist.TreeOrder)-1 {
151 | for _, compare := range c.Playlist.TreeOrder[i+1:] {
152 | if sameStrSlice(c.Playlist.Tree[label].Sort, c.Playlist.Tree[compare].Sort) {
153 | return fmt.Errorf("playlist.tree.*.sort must be unique: playlist.tree.%s.sort and playlist.tree.%s.sort has same value", label, compare)
154 | }
155 | }
156 | }
157 | }
158 | if t, o := len(c.Playlist.Tree), len(c.Playlist.TreeOrder); o != t {
159 | return fmt.Errorf("playlist.tree length (%d) and playlist.tree_order length (%d) mismatch", t, o)
160 | }
161 | return nil
162 | }
163 |
164 | func sameStrSlice(a, b []string) bool {
165 | if len(a) != len(b) {
166 | return false
167 | }
168 | for i, s := range a {
169 | if b[i] != s {
170 | return false
171 | }
172 | }
173 | return true
174 | }
175 |
176 | var (
177 | supportTreeViews = []string{"plain", "album", "song"}
178 | )
179 |
180 | // ConfigListNode represents smart playlist node.
181 | type ConfigListNode struct {
182 | Sort []string `yaml:"sort"`
183 | Tree [][2]string `yaml:"tree"`
184 | }
185 |
186 | // Validate ConfigListNode data struct.
187 | func (l *ConfigListNode) Validate() error {
188 | if len(l.Tree) == 0 || len(l.Sort) == 0 {
189 | return errors.New("sort or tree must not be empty")
190 | }
191 | if len(l.Tree) > 4 {
192 | return fmt.Errorf("maximum tree length is 4; got %d", len(l.Tree))
193 | }
194 | for i, leef := range l.Tree {
195 | if !contains(l.Sort, leef[0]) {
196 | return fmt.Errorf("tree: index %d:0: tree tag must be defined in sort: %s does not defined in sort: %v", i, leef[0], l.Sort)
197 | }
198 | if !contains(supportTreeViews, leef[1]) {
199 | return fmt.Errorf("tree: index %d:1: unsupported tree view type: got %s; supported tree element views are %v", i, leef[1], supportTreeViews)
200 | }
201 | }
202 | return nil
203 | }
204 |
205 | // toTree shallow copies config tree to vv.Tree.
206 | func toTree(t map[string]*ConfigListNode) vv.Tree {
207 | if t == nil {
208 | return nil
209 | }
210 | ret := make(vv.Tree, len(t))
211 | for k, v := range t {
212 | ret[k] = &vv.TreeNode{
213 | Sort: v.Sort,
214 | Tree: v.Tree,
215 | }
216 | }
217 | return ret
218 | }
219 |
220 | // BinarySize represents a number of binary size.
221 | type BinarySize uint64
222 |
223 | // MarshalText returns number as IEC prefixed binary size.
224 | func (b BinarySize) MarshalText() ([]byte, error) {
225 | buf := &bytes.Buffer{}
226 | if b%1024 != 0 {
227 | fmt.Fprintf(buf, "%d B", b)
228 | return buf.Bytes(), nil
229 | }
230 | k := b / 1024
231 | if k%1024 != 0 {
232 | fmt.Fprintf(buf, "%d KiB", k)
233 | return buf.Bytes(), nil
234 | }
235 | m := k / 1024
236 | fmt.Fprintf(buf, "%d MiB", m)
237 | return buf.Bytes(), nil
238 | }
239 |
240 | // UnmarshalText parses binary size with suffix like KiB, MB, m.
241 | func (b *BinarySize) UnmarshalText(text []byte) error {
242 | text = bytes.TrimSpace(text)
243 | var num []byte
244 | var suffixB []byte
245 | f := true
246 | for _, l := range text {
247 | if f && '0' <= l && l <= '9' {
248 | num = append(num, l)
249 | } else {
250 | f = false
251 | suffixB = append(suffixB, l)
252 | }
253 | }
254 | n, err := strconv.ParseUint(string(num), 10, 64)
255 | if err != nil {
256 | return err
257 | }
258 | suffix := strings.TrimSpace(string(suffixB))
259 | if suffix == "k" || suffix == "K" || suffix == "KiB" || suffix == "KB" || suffix == "kB" {
260 | *b = BinarySize(n) * 1024
261 | } else if suffix == "m" || suffix == "M" || suffix == "MiB" || suffix == "MB" {
262 | *b = BinarySize(n) * 1024 * 1024
263 | } else if suffix == "" || suffix == "B" {
264 | *b = BinarySize(n)
265 | } else {
266 | return fmt.Errorf("unknown size suffix: %s", suffix)
267 | }
268 | return nil
269 | }
270 |
271 | func contains(list []string, item string) bool {
272 | for _, n := range list {
273 | if item == n {
274 | return true
275 | }
276 | }
277 | return false
278 | }
279 |
--------------------------------------------------------------------------------
/internal/vv/lang.go:
--------------------------------------------------------------------------------
1 | package vv
2 |
3 | import (
4 | "net/http"
5 |
6 | "golang.org/x/text/language"
7 | )
8 |
9 | var langPrio = []language.Tag{
10 | language.AmericanEnglish,
11 | language.Japanese,
12 | }
13 |
14 | var langMatcher = language.NewMatcher(langPrio)
15 |
16 | func determineLang(r *http.Request) (language.Tag, int) {
17 | t, _, _ := language.ParseAcceptLanguage(r.Header.Get("Accept-Language"))
18 | _, i, _ := langMatcher.Match(t...)
19 | return langPrio[i], i
20 | }
21 |
22 | var langData = map[language.Tag]map[string]string{
23 | language.AmericanEnglish: {},
24 | language.Japanese: {
25 | "lang": "ja",
26 | "Preferences": "環境設定",
27 | "Database": "データベース",
28 | "Outputs": "出力",
29 | "Information": "情報",
30 | "ReloadApplication": "再読み込み",
31 | "Appearance": "外観の設定",
32 | "General": "一般",
33 | "Theme": "テーマ",
34 | "ThemeLight": "ライト",
35 | "ThemeDark": "ダーク",
36 | "ThemeSystem": "システムに合わせる",
37 | "ThemeCoverArt": "カバーアートに合わせる",
38 | "CoverArtColorThreshold": "色閾値",
39 | "Animation": "アニメーション",
40 | "BackgroundImage": "背景画像を表示",
41 | "BackgroundImageBlur": "背景のブラーエフェクト",
42 | "CircledImage": "カバーアートを丸く表示",
43 | "CrossfadingImage": "画像のクロスフェード",
44 | "GridviewAlbum": "アルバム一覧をグリッド表示",
45 | "AutoHideScrollbar": "自動的にスクロールバーを非表示",
46 | "Playlist": "プレイリスト",
47 | "PlaybackRange": "再生範囲",
48 | "PlayAllTracks": "すべての曲を再生",
49 | "PlaySelectedList": "現在のリストを再生",
50 | "PlaybackRangeHelp": "次曲選択以降に有効になります。",
51 | "PlayCustomList": "カスタム",
52 | "playbackTreeLabelPrefix": "",
53 | "playbackTreeLabelSuffix": "ツリー",
54 | "allTracks": "すべての曲",
55 | "Library": "ライブラリ",
56 | "Rescan": "ライブラリを再読み込み",
57 | "Songs": "曲",
58 | "RescanSongs": "再読み込み",
59 | "CoverArt": "カバーアート",
60 | "RescanCoverArt": "再読み込み",
61 | "Playback": "再生",
62 | "ListviewFollowsPlayback": "次に再生する曲に自動で移動",
63 | "Volume": "音量",
64 | "ShowVolumeNob": "音量バーを表示",
65 | "MaxVolume": "音量の最大値",
66 | "Configure": "設定",
67 | "Devices": "デバイス",
68 | "DeviceDoPHelp": "DSDストリームをPCMフレームに乗せて送信し、受信側のDACでDSDストリームに戻します。DoPに対応していないDACでは使用しないでください",
69 | "DeviceAllowedFormats": "出力フォーマット",
70 | "DeviceAllowedFormatsHelp": "出力を許可するフォーマットを指定します",
71 | "AllowedFormats": "出力フォーマット",
72 | "AllowedFormatsAuto": "自動",
73 | "AllowedFormatsCustom": "カスタム",
74 | "Tracks": "トラック数:",
75 | "Artists": "アーティスト数:",
76 | "Albums": "アルバム数:",
77 | "TotalLength": "総トラック時間:",
78 | "TotalPlaytime": "総再生時間:",
79 | "Uptime": "起動時間:",
80 | "LastLibraryUpdate": "最終更新:",
81 | "Websockets": "Websocket接続数",
82 | "Storage": "ストレージ",
83 | "UnmountStorage": "削除",
84 | "MountStorage": "ストレージを追加",
85 | "StoragePath": "ストレージ名",
86 | "StorageURI": "URI",
87 | "Options": "オプション",
88 | "ReplayGain": "リプレイゲイン",
89 | "ReplayGainOff": "オフ",
90 | "ReplayGainTrack": "トラック",
91 | "ReplayGainAlbum": "アルバム",
92 | "Crossfade": "クロスフェード",
93 | "ClientOutput": "クライアント出力",
94 | "ClientOutputSource": "ソース",
95 | "ClientOutputSourceHelp": "HTTP出力を再生します",
96 | "HTTPStreamDisabled": "無効",
97 | "HTTPStreamVolume": "音量",
98 | "ThisApplication": "このアプリケーションについて",
99 | "Compiler": "コンパイラ",
100 | "BSD3ClauseLicense": "修正BSDライセンス",
101 | "Renderer": "ブラウザ",
102 | "Credits": "クレジット",
103 | "MITLicense": "MIT ライセンス",
104 | "BSD2ClauseLicense": "二条項BSDライセンス",
105 | "Help": "ヘルプ",
106 | "KeyboardShortcuts": "キーボード・ショートカット",
107 | "PlayOrPauseSong": "再生・一時停止",
108 | "GoToNextSong": "次の曲",
109 | "GoToPreviousSong": "前の曲",
110 | "MoveListViewCursor": "カーソル移動",
111 | "ActivateListViewCursorItem": "カーソル上の曲を再生/下のディレクトリへ移動",
112 | "ShowListParent": "上のディレクトリへ移動",
113 | "ShowNowPlayingItem": "現在再生中の曲へ移動",
114 | "ShowThisHelp": "このヘルプを表示",
115 | "ariaLabelBackTo": "%s 一覧に移動",
116 | "ariaLabelShowNowPlayingItem": "現在再生中の曲へ移動",
117 | "ariaLabelShowSettingsWindow": "設定ウィンドウを表示",
118 | "ariaLabelCloseThisWindow": "ウィンドウを閉じる",
119 | "ariaLabelGoToPreviousSong": "前の曲に戻る",
120 | "ariaLabelGoToNextSong": "次の曲を再生",
121 | "ariaLabelPauseSong": "一時停止",
122 | "ariaLabelPlaySong": "再生",
123 | "ariaLabelTurnOnRepeat": "リピート再生を有効にする",
124 | "ariaLabelTurnOnRepeat1": "1曲リピート再生を有効にする",
125 | "ariaLabelTurnOffRepeat": "リピート再生を無効にする",
126 | "ariaLabelTurnOnRandom": "ランダム再生を有効にする",
127 | "ariaLabelTurnOffRandom": "ランダム再生を無効にする",
128 | "titleFormatBackTo": "%s に戻る",
129 | "titleShowNowPlayingItem": "現在再生中の曲へ移動",
130 | "titleShowSettingsWindow": "設定",
131 | "titleClose": "閉じる",
132 | "titlePrevious": "前の曲",
133 | "titlePlayOrPause": "再生・一時停止",
134 | "titleNext": "次の曲",
135 | "titleRepeat": "リピート",
136 | "titleRandom": "ランダム",
137 | "SongInfoTitle": "名前",
138 | "SongInfoArtist": "アーティスト",
139 | "SongInfoAlbum": "アルバム",
140 | "SongInfoAlbumArtist": "アルバムアーティスト",
141 | "SongInfoComposer": "作曲者",
142 | "SongInfoPerformer": "演奏者",
143 | "SongInfoDate": "日付",
144 | "SongInfoDisc": "ディスク",
145 | "SongInfoTrack": "トラック",
146 | "SongInfoLength": "時間",
147 | "SongInfoGenre": "ジャンル",
148 | "NotifyNetwork": "ネットワーク",
149 | "NotifyNetworkTimeoutRetry": "タイムアウト. 再接続中...",
150 | "NotifyNetworkTimeout": "タイムアウト",
151 | "NotifyNetworkClosed": "再接続中...",
152 | "NotifyNetworkDoesNotRespond": "再接続中...",
153 | "NotifyMPDReconnecting": "再接続中...",
154 | "NotifyClientOutput": "クライアント出力",
155 | "NotifyClientOutputNetworkError": "ネットワークエラー",
156 | "NotifyClientOutputDeocdeError": "デコードエラー",
157 | "NotifyClientOutputUnsupportedSource": "未対応のフォーマット又はネットワークエラー",
158 | "NotifyClientOutputNotAllowed": "自動再生が許可されていません",
159 | "NotifyClientOutputRetry": "再試行",
160 | "NotifyClientOutputOpenSettings": "設定を開く",
161 | "NotifyLibrary": "ライブラリ",
162 | "NotifyLibraryUpdating": "更新中...",
163 | "NotifyLibraryUpdated": "更新済み",
164 | "NotifyCoverArt": "カバーアート",
165 | "NotifyCoverArtUpdating": "更新中...",
166 | "NotifyCoverArtUpdated": "更新済み",
167 | },
168 | }
169 |
--------------------------------------------------------------------------------