├── 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /internal/vv/api/images/testdata/app-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 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 | --------------------------------------------------------------------------------