From a069aaba706253ec026e334c0d7d93181c6b2578 Mon Sep 17 00:00:00 2001 From: sentriz Date: Wed, 3 Nov 2021 23:05:08 +0000 Subject: [PATCH] refactor: update scanner, scanner tests, mockfs closes #165 closes #163 --- .github/workflows/nightly-release.yaml | 4 +- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 2 +- .golangci.yml | 86 +- cmd/gonic/gonic.go | 7 +- gen_handler_tests | 51 -- go.mod | 29 +- go.sum | 79 +- multierr/multierr.go | 7 +- server/ctrladmin/handlers.go | 74 +- server/ctrladmin/handlers_playlist.go | 2 +- server/ctrlsubsonic/ctrl.go | 1 + server/ctrlsubsonic/ctrl_test.go | 48 +- server/ctrlsubsonic/handlers_bookmark.go | 3 +- server/ctrlsubsonic/handlers_by_folder.go | 12 +- .../ctrlsubsonic/handlers_by_folder_test.go | 28 +- server/ctrlsubsonic/handlers_by_tags.go | 15 +- server/ctrlsubsonic/handlers_by_tags_test.go | 36 +- server/ctrlsubsonic/handlers_common.go | 9 +- server/ctrlsubsonic/handlers_playlist.go | 5 +- server/ctrlsubsonic/handlers_raw.go | 6 +- server/ctrlsubsonic/testdata/db | Bin 192512 -> 0 bytes .../testdata/test_get_album_list_alpha_artist | 152 ++-- .../testdata/test_get_album_list_alpha_name | 152 ++-- .../testdata/test_get_album_list_newest | 156 ++-- .../testdata/test_get_album_list_random | 156 ++-- .../test_get_album_list_two_alpha_artist | 172 ++-- .../test_get_album_list_two_alpha_name | 168 ++-- .../testdata/test_get_album_list_two_newest | 170 ++-- .../testdata/test_get_album_list_two_random | 168 ++-- .../testdata/test_get_album_with_cover | 117 +-- .../testdata/test_get_album_without_cover | 80 +- .../testdata/test_get_artist_id_one | 42 +- .../testdata/test_get_artist_id_three | 49 +- .../testdata/test_get_artist_id_two | 48 +- .../testdata/test_get_artists_no_args | 63 +- .../testdata/test_get_indexes_no_args | 63 +- .../test_get_music_directory_with_tracks | 96 +-- .../test_get_music_directory_without_tracks | 60 +- .../testdata/test_search_three_q_13 | 31 - .../testdata/test_search_three_q_alb | 128 +++ .../testdata/test_search_three_q_ani | 31 - .../testdata/test_search_three_q_art | 14 + .../testdata/test_search_three_q_cert | 16 - .../testdata/test_search_three_q_tra | 431 ++++++++++ .../testdata/test_search_two_q_13 | 148 ---- .../testdata/test_search_two_q_alb | 97 +++ .../testdata/test_search_two_q_ani | 26 - .../testdata/test_search_two_q_art | 8 + .../testdata/test_search_two_q_cert | 15 - .../testdata/test_search_two_q_tra | 391 +++++++++ server/db/db.go | 72 +- server/db/db_test.go | 42 +- server/db/migrations.go | 23 +- server/db/model.go | 2 +- server/mockfs/mockfs.go | 269 ++++++ server/podcasts/podcasts.go | 81 +- server/scanner/scanner.go | 766 ++++++++---------- server/scanner/scanner_test.go | 339 +++++++- server/scanner/stack/stack.go | 61 -- server/scanner/stack/stack_test.go | 36 - server/scanner/tags/tags.go | 94 ++- server/scrobble/lastfm/lastfm.go | 13 +- server/server.go | 30 +- 64 files changed, 3347 insertions(+), 2235 deletions(-) delete mode 100755 gen_handler_tests delete mode 100644 server/ctrlsubsonic/testdata/db delete mode 100644 server/ctrlsubsonic/testdata/test_search_three_q_13 create mode 100644 server/ctrlsubsonic/testdata/test_search_three_q_alb delete mode 100644 server/ctrlsubsonic/testdata/test_search_three_q_ani create mode 100644 server/ctrlsubsonic/testdata/test_search_three_q_art delete mode 100644 server/ctrlsubsonic/testdata/test_search_three_q_cert create mode 100644 server/ctrlsubsonic/testdata/test_search_three_q_tra delete mode 100644 server/ctrlsubsonic/testdata/test_search_two_q_13 create mode 100644 server/ctrlsubsonic/testdata/test_search_two_q_alb delete mode 100644 server/ctrlsubsonic/testdata/test_search_two_q_ani create mode 100644 server/ctrlsubsonic/testdata/test_search_two_q_art delete mode 100644 server/ctrlsubsonic/testdata/test_search_two_q_cert create mode 100644 server/ctrlsubsonic/testdata/test_search_two_q_tra create mode 100644 server/mockfs/mockfs.go delete mode 100644 server/scanner/stack/stack.go delete mode 100644 server/scanner/stack/stack_test.go diff --git a/.github/workflows/nightly-release.yaml b/.github/workflows/nightly-release.yaml index 73a8b294..67ee93d7 100644 --- a/.github/workflows/nightly-release.yaml +++ b/.github/workflows/nightly-release.yaml @@ -1,7 +1,7 @@ name: Nightly Release on: schedule: - - cron: '0 0 * * *' + - cron: "0 0 * * *" workflow_dispatch: {} jobs: test: @@ -21,7 +21,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v2 with: - version: v1.40.0 + version: v1.42.1 skip-go-installation: true - name: Test run: go test ./... diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 52d5b9de..649edff2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,7 +21,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v2 with: - version: v1.40.0 + version: v1.42.1 skip-go-installation: true - name: Test run: go test ./... diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 86ee535e..38f1837a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,7 +22,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v2 with: - version: v1.40.0 + version: v1.42.1 skip-go-installation: true - name: Test run: go test ./... diff --git a/.golangci.yml b/.golangci.yml index 1e76e5d5..0259f855 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,57 +1,51 @@ run: skip-dirs: - - server/assets + - server/assets skip-dirs-use-default: true linters: disable-all: true enable: - - bodyclose - - deadcode - - depguard - - dogsled - - errcheck - - exportloopref - - gochecknoglobals - - gochecknoinits - - goconst - - gocritic - - gocyclo - - goerr113 - - golint - - goprintffuncname - - gosec - - gosimple - - govet - - ineffassign - - lll - - misspell - - nakedret - - rowserrcheck - - staticcheck - - structcheck - - stylecheck - - typecheck - - unconvert - - varcheck - -issues: - exclude-rules: - - path: _test\.go - linters: + - bodyclose + - deadcode + - depguard + - dogsled - errcheck + - exportloopref - gochecknoglobals - - text: "weak cryptographic primitive" - linters: - - gosec - - text: "weak random number generator" - linters: + - gochecknoinits + - goconst + - gocritic + - gocyclo + - goerr113 + - goprintffuncname - gosec - - # TODO: fix these - - text: "should have comment" - linters: - - golint - - text: "at least one file in a package should have a package comment" - linters: + - gosimple + - govet + - ineffassign + - misspell + - nakedret + - revive + - rowserrcheck + - staticcheck + - structcheck - stylecheck + - typecheck + - unconvert + - varcheck + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck + - gochecknoglobals + - text: "weak cryptographic primitive" + linters: + - gosec + - text: "weak random number generator" + linters: + - gosec + - text: "at least one file in a package should have a package comment" + linters: + - stylecheck diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 3cb91163..dafdeae0 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -86,7 +86,7 @@ func main() { } } - db, err := db.New(*confDBPath) + dbc, err := db.New(*confDBPath, db.DefaultOptions()) if err != nil { log.Fatalf("error opening database: %v\n", err) } @@ -106,8 +106,7 @@ func main() { JukeboxEnabled: *confJukeboxEnabled, }) if err != nil { - log.Printf("error creating server: %v\n", err) - return + log.Panicf("error creating server: %v\n", err) } var g run.Group @@ -123,6 +122,6 @@ func main() { } if err := g.Run(); err != nil { - log.Printf("error in job: %v", err) + log.Panicf("error in job: %v", err) } } diff --git a/gen_handler_tests b/gen_handler_tests deleted file mode 100755 index a2951383..00000000 --- a/gen_handler_tests +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash - -test_listen_addr=localhost:9353 -test_data_path=server/ctrlsubsonic/testdata -test_db_path=$test_data_path/db - -test_music_path=~/music -test_podcast_path="$(mktemp -d)" -test_cache_path="$(mktemp -d)" - -mkdir "$test_music_path" 2>/dev/null -echo "waiting for server to start" -go run cmd/gonic/gonic.go \ - -music-path "$test_music_path" \ - -podcast-path "$test_podcast_path" \ - -cache-path "$test_cache_path" \ - -db-path "$test_db_path" \ - -listen-addr "$test_listen_addr" 2>&1 \ - | while read line; do - echo "from server: $line" - [[ "$line" != *$'starting job \'http\''* ]] && continue - sleep '0.5' - # ** begin by folder - curl -s "http://$test_listen_addr/rest/getAlbumList.view?c=c&f=json&p=admin&u=admin&v=v&type=alphabeticalByArtist" | jq >"$test_data_path/test_get_album_list_alpha_artist" - curl -s "http://$test_listen_addr/rest/getAlbumList.view?c=c&f=json&p=admin&u=admin&v=v&type=alphabeticalByName" | jq >"$test_data_path/test_get_album_list_alpha_name" - curl -s "http://$test_listen_addr/rest/getAlbumList.view?c=c&f=json&p=admin&u=admin&v=v&type=alphabeticalByName" | jq >"$test_data_path/test_get_album_list_two_alpha_name" - curl -s "http://$test_listen_addr/rest/getAlbumList.view?c=c&f=json&p=admin&u=admin&v=v&type=newest" | jq >"$test_data_path/test_get_album_list_newest" - curl -s "http://$test_listen_addr/rest/getAlbumList.view?c=c&f=json&p=admin&u=admin&v=v&type=random&size=15" | jq >"$test_data_path/test_get_album_list_random" - curl -s "http://$test_listen_addr/rest/search2.view?c=c&f=json&p=admin&u=admin&v=v&query=13" | jq >"$test_data_path/test_search_two_q_13" - curl -s "http://$test_listen_addr/rest/search2.view?c=c&f=json&p=admin&u=admin&v=v&query=ani" | jq >"$test_data_path/test_search_two_q_ani" - curl -s "http://$test_listen_addr/rest/search2.view?c=c&f=json&p=admin&u=admin&v=v&query=cert" | jq >"$test_data_path/test_search_two_q_cert" - curl -s "http://$test_listen_addr/rest/getIndexes.view?c=c&p=admin&u=admin&v=v&f=json" | jq >"$test_data_path/test_get_indexes_no_args" - curl -s "http://$test_listen_addr/rest/getMusicDirectory.view?c=Jamsstash&id=al-2&p=admin&u=admin&v=v&f=json" | jq >"$test_data_path/test_get_music_directory_without_tracks" - curl -s "http://$test_listen_addr/rest/getMusicDirectory.view?c=Jamsstash&id=al-3&p=admin&u=admin&v=v&f=json" | jq >"$test_data_path/test_get_music_directory_with_tracks" - # ** begin by tags - curl -s "http://$test_listen_addr/rest/getAlbum.view?c=c&f=json&p=admin&u=admin&v=v&id=al-2" | jq >"$test_data_path/test_get_album_without_cover" - curl -s "http://$test_listen_addr/rest/getAlbum.view?c=c&f=json&p=admin&u=admin&v=v&id=al-3" | jq >"$test_data_path/test_get_album_with_cover" - curl -s "http://$test_listen_addr/rest/getAlbumList2.view?c=c&f=json&p=admin&u=admin&v=v&type=alphabeticalByArtist" | jq >"$test_data_path/test_get_album_list_two_alpha_artist" - curl -s "http://$test_listen_addr/rest/getAlbumList2.view?c=c&f=json&p=admin&u=admin&v=v&type=alphabeticalByName" | jq >"$test_data_path/test_get_album_list_two_alpha_name" - curl -s "http://$test_listen_addr/rest/getAlbumList2.view?c=c&f=json&p=admin&u=admin&v=v&type=newest" | jq >"$test_data_path/test_get_album_list_two_newest" - curl -s "http://$test_listen_addr/rest/getAlbumList2.view?c=c&f=json&p=admin&u=admin&v=v&type=random&size=15" | jq >"$test_data_path/test_get_album_list_two_random" - curl -s "http://$test_listen_addr/rest/getArtist.view?c=c&f=json&p=admin&u=admin&v=v&id=ar-1" | jq >"$test_data_path/test_get_artist_id_one" - curl -s "http://$test_listen_addr/rest/getArtist.view?c=c&f=json&p=admin&u=admin&v=v&id=ar-2" | jq >"$test_data_path/test_get_artist_id_two" - curl -s "http://$test_listen_addr/rest/getArtist.view?c=c&f=json&p=admin&u=admin&v=v&id=ar-3" | jq >"$test_data_path/test_get_artist_id_three" - curl -s "http://$test_listen_addr/rest/getArtists.view?c=c&f=json&p=admin&u=admin&v=v" | jq >"$test_data_path/test_get_artists_no_args" - curl -s "http://$test_listen_addr/rest/search3.view?c=c&f=json&p=admin&u=admin&v=v&query=13" | jq >"$test_data_path/test_search_three_q_13" - curl -s "http://$test_listen_addr/rest/search3.view?c=c&f=json&p=admin&u=admin&v=v&query=ani" | jq >"$test_data_path/test_search_three_q_ani" - curl -s "http://$test_listen_addr/rest/search3.view?c=c&f=json&p=admin&u=admin&v=v&query=cert" | jq >"$test_data_path/test_search_three_q_cert" - # - pkill -INT -f "/tmp/go-build.*$test_listen_addr.*" - done diff --git a/go.mod b/go.mod index ad6ccb48..91310dfb 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,8 @@ require ( github.com/cespare/xxhash v1.1.0 github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.0 - github.com/faiface/beep v1.0.3-0.20210817042730-1c98bf641535 - github.com/google/uuid v1.1.2 // indirect - github.com/gopherjs/gopherwasm v1.0.0 // indirect + github.com/faiface/beep v1.1.0 + github.com/google/uuid v1.3.0 // indirect github.com/gorilla/mux v1.8.0 github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.2.1 @@ -21,23 +20,21 @@ require ( github.com/jinzhu/gorm v1.9.16 github.com/josephburnett/jd v0.0.0-20191228205456-aa1a7c66b42f github.com/karrick/godirwalk v1.16.1 - github.com/kr/pretty v0.1.0 // indirect - github.com/mewkiz/pkg v0.0.0-20200702171441-dd47075182ea // indirect - github.com/mitchellh/copystructure v1.0.0 // indirect - github.com/mitchellh/reflectwalk v1.0.1 // indirect - github.com/mmcdole/gofeed v1.1.0 + github.com/matryer/is v1.4.0 + github.com/mewkiz/pkg v0.0.0-20211102230744-16a6ce8f1b77 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mmcdole/gofeed v1.1.3 + github.com/mmcdole/goxpp v0.0.0-20200921145534-2f3784f67354 // indirect github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd github.com/oklog/run v1.1.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c github.com/peterbourgon/ff v1.7.0 - github.com/pkg/errors v0.9.1 // indirect github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be - github.com/wader/gormstore v0.0.0-20200328121358-65a111a20c23 - golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect - golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 // indirect - golang.org/x/image v0.0.0-20201208152932-35266b937fa6 // indirect - golang.org/x/sys v0.0.0-20201223074533-0d417f636930 // indirect - gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 // indirect - gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + github.com/wader/gormstore v0.0.0-20211009162750-8bf4f5606ef4 + golang.org/x/exp v0.0.0-20211103171733-83d51122435b // indirect + golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect + golang.org/x/mobile v0.0.0-20211103151657-e68c98865fb2 // indirect + golang.org/x/net v0.0.0-20211104170005-ce137452f963 // indirect + golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect gopkg.in/gormigrate.v1 v1.6.0 ) diff --git a/go.sum b/go.sum index c26f371b..a6322913 100644 --- a/go.sum +++ b/go.sum @@ -31,12 +31,13 @@ github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/faiface/beep v1.0.2 h1:UB5DiRNmA4erfUYnHbgU4UB6DlBOrsdEFRtcc8sCkdQ= -github.com/faiface/beep v1.0.2/go.mod h1:1yLb5yRdHMsovYYWVqYLioXkVuziCSITW1oarTeduQM= -github.com/faiface/beep v1.0.3-0.20210817042730-1c98bf641535 h1:391d1LXITcjNUsoeXUY21E5UCsmFz/W3ft9sInjynDI= -github.com/faiface/beep v1.0.3-0.20210817042730-1c98bf641535/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= +github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= +github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= +github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/tcell v1.1.1/go.mod h1:K1udHkiR3cOtlpKG5tZPD5XxrF7v2y7lDq7Whcj+xkQ= github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= @@ -53,10 +54,6 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopherjs/gopherjs v0.0.0-20180628210949-0892b62f0d9f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherwasm v0.1.1/go.mod h1:kx4n9a+MzHH0BJJhvlsQ65hqLFXDO/m256AsaDPQ+/4= -github.com/gopherjs/gopherwasm v1.0.0/go.mod h1:SkZ8z7CWBz5VXbhJel8TxCmAcsQqzgWGR/8nMhyhZSI= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= @@ -66,15 +63,10 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/hajimehoshi/go-mp3 v0.1.1/go.mod h1:4i+c5pDNKDrxl1iu9iG90/+fhP37lio6gNhjCx9WBJw= github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= github.com/hajimehoshi/go-mp3 v0.3.1 h1:pn/SKU1+/rfK8KaZXdGEC2G/KCB2aLRjbTCrwKcokao= github.com/hajimehoshi/go-mp3 v0.3.1/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= -github.com/hajimehoshi/oto v0.1.1/go.mod h1:hUiLWeBQnbDu4pZsAhOnGqMI1ZGibS6e2qhQdfpwz04= -github.com/hajimehoshi/oto v0.3.1/go.mod h1:e9eTLBB9iZto045HLbzfHJIc+jP3xaKrjZTghvb6fdM= github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= -github.com/hajimehoshi/oto v0.7.0 h1:4HbTRhNuHd4SdFfA4vhIgmwvVO3qWueHK+fF1cButpg= -github.com/hajimehoshi/oto v0.7.0/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk= github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= @@ -83,9 +75,10 @@ github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8= github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= -github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/jfreymuth/oggvorbis v1.0.0/go.mod h1:abe6F9QRjuU9l+2jek3gj46lu40N4qlYxh2grqkLEDM= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= @@ -98,6 +91,9 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3/go.mod h1:oHTiXerJ20+SfYcrdlBO7rzZRJWGwSTQ0iUY2jI6Gfc= github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= +github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/josephburnett/jd v0.0.0-20191228205456-aa1a7c66b42f h1:ijUonnyvDekPD7lUF4oQ1LV+dKaTnchEzmenMFa6NL4= @@ -106,26 +102,24 @@ github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lucasb-eyer/go-colorful v0.0.0-20181028223441-12d3b2882a08/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mewkiz/flac v1.0.5/go.mod h1:EHZNU32dMF6alpurYyKHDLYpW1lYpBZ5WrXi/VuNIGs= -github.com/mewkiz/flac v1.0.6 h1:OnMwCWZPAnjDndjEzLynOZ71Y2U+/QYHoVI4JEKgKkk= -github.com/mewkiz/flac v1.0.6/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= github.com/mewkiz/flac v1.0.7 h1:uIXEjnuXqdRaZttmSFM5v5Ukp4U6orrZsnYGGR3yow8= github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= @@ -134,16 +128,19 @@ github.com/mewkiz/pkg v0.0.0-20200702171441-dd47075182ea/go.mod h1:3E2FUC/qYUfM8 github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY70sEEpE= -github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mmcdole/gofeed v1.1.0 h1:T2WrGLVJRV04PY2qwhEJLHCt9JiCtBhb6SmC8ZvJH08= -github.com/mmcdole/gofeed v1.1.0/go.mod h1:PPiVwgDXLlz2N83KB4TrIim2lyYM5Zn7ZWH9Pi4oHUk= -github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mmcdole/gofeed v1.1.3 h1:pdrvMb18jMSLidGp8j0pLvc9IGziX4vbmvVqmLH6z8o= +github.com/mmcdole/gofeed v1.1.3/go.mod h1:QQO3maftbOu+hiVOGOZDRLymqGQCos4zxbA4j89gMrE= github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd h1:xKn/gU8lZupoZt/HE7a/R3aH93iUO6JwyRsYelQUsRI= github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd/go.mod h1:B6icauz2l4tkYQxmDtCH4qmNWz/evSW5CsOqp6IE5IE= @@ -179,19 +176,16 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/exp v0.0.0-20180710024300-14dda7b62fcd/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 h1:XlAInxBYX5nBofPaY51uv/x9xmRgZGr/lDOsePd2AcE= golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI= golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/mobile v0.0.0-20180806140643-507816974b79/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f h1:kgfVkAEEQXXQ0qc6dH7n6y37NAYmTFmz0YRwrRjgxKw= @@ -202,6 +196,14 @@ golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hM golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -211,10 +213,10 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -236,10 +238,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbO golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/gormigrate.v1 v1.6.0 h1:XpYM6RHQPmzwY7Uyu+t+xxMXc86JYFJn4nEc9HzQjsI= gopkg.in/gormigrate.v1 v1.6.0/go.mod h1:Lf00lQrHqfSYWiTtPcyQabsDdM6ejZaMgV0OU6JMSlw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/multierr/multierr.go b/multierr/multierr.go index 04bd4d96..d5946b0e 100644 --- a/multierr/multierr.go +++ b/multierr/multierr.go @@ -5,11 +5,12 @@ import "strings" type Err []error func (me Err) Error() string { - var strs []string + var builder strings.Builder for _, err := range me { - strs = append(strs, err.Error()) + builder.WriteString("\n") + builder.WriteString(err.Error()) } - return strings.Join(strs, "\n") + return builder.String() } func (me Err) Len() int { diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index de725cf9..4fca68c4 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -27,7 +27,7 @@ func firstExisting(or string, strings ...string) string { func doScan(scanner *scanner.Scanner, opts scanner.ScanOptions) { go func() { - if err := scanner.Start(opts); err != nil { + if err := scanner.ScanAndClean(opts); err != nil { log.Printf("error while scanning: %v\n", err) } }() @@ -43,11 +43,11 @@ func (c *Controller) ServeLogin(r *http.Request) *Response { func (c *Controller) ServeHome(r *http.Request) *Response { data := &templateData{} - // ** begin stats box + // stats box c.DB.Table("artists").Count(&data.ArtistCount) c.DB.Table("albums").Count(&data.AlbumCount) c.DB.Table("tracks").Count(&data.TrackCount) - // ** begin lastfm box + // lastfm box scheme := firstExisting( "http", // fallback r.Header.Get("X-Forwarded-Proto"), @@ -60,36 +60,37 @@ func (c *Controller) ServeHome(r *http.Request) *Response { r.Host, ) data.RequestRoot = fmt.Sprintf("%s://%s", scheme, host) - data.CurrentLastFMAPIKey = c.DB.GetSetting("lastfm_api_key") + data.CurrentLastFMAPIKey, _ = c.DB.GetSetting("lastfm_api_key") data.DefaultListenBrainzURL = listenbrainz.BaseURL - // ** begin users box + // users box c.DB.Find(&data.AllUsers) - // ** begin recent folders box + // recent folders box c.DB. Where("tag_artist_id IS NOT NULL"). Order("modified_at DESC"). Limit(8). Find(&data.RecentFolders) - data.IsScanning = scanner.IsScanning() - if tStr := c.DB.GetSetting("last_scan_time"); tStr != "" { + data.IsScanning = c.Scanner.IsScanning() + if tStr, err := c.DB.GetSetting("last_scan_time"); err != nil { i, _ := strconv.ParseInt(tStr, 10, 64) data.LastScanTime = time.Unix(i, 0) } - // + user := r.Context().Value(CtxUser).(*db.User) - // ** begin playlists box + + // playlists box c.DB. Where("user_id=?", user.ID). Limit(20). Find(&data.Playlists) - // ** begin transcoding box + // transcoding box c.DB. Where("user_id=?", user.ID). Find(&data.TranscodePreferences) for profile := range encode.Profiles() { data.TranscodeProfiles = append(data.TranscodeProfiles, profile) } - // ** begin podcasts box + // podcasts box c.DB.Find(&data.Podcasts) // return &Response{ @@ -143,11 +144,21 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { code: 400, } } - sessionKey, err := lastfm.GetSession( - c.DB.GetSetting("lastfm_api_key"), - c.DB.GetSetting("lastfm_secret"), - token, - ) + apiKey, err := c.DB.GetSetting("lastfm_api_key") + if err != nil { + return &Response{ + err: fmt.Sprintf("couldn't get api key: %v", err), + code: 500, + } + } + secret, err := c.DB.GetSetting("lastfm_secret") + if err != nil { + return &Response{ + err: fmt.Sprintf("couldn't get secret: %v", err), + code: 500, + } + } + sessionKey, err := lastfm.GetSession(apiKey, secret, token) if err != nil { return &Response{ redirect: "/admin/home", @@ -341,8 +352,19 @@ func (c *Controller) ServeCreateUserDo(r *http.Request) *Response { func (c *Controller) ServeUpdateLastFMAPIKey(r *http.Request) *Response { data := &templateData{} - data.CurrentLastFMAPIKey = c.DB.GetSetting("lastfm_api_key") - data.CurrentLastFMAPISecret = c.DB.GetSetting("lastfm_secret") + var err error + if data.CurrentLastFMAPIKey, err = c.DB.GetSetting("lastfm_api_key"); err != nil { + return &Response{ + err: fmt.Sprintf("couldn't get api key: %v", err), + code: 500, + } + } + if data.CurrentLastFMAPISecret, err = c.DB.GetSetting("lastfm_secret"); err != nil { + return &Response{ + err: fmt.Sprintf("couldn't get secret: %v", err), + code: 500, + } + } return &Response{ template: "update_lastfm_api_key.tmpl", data: data, @@ -358,8 +380,18 @@ func (c *Controller) ServeUpdateLastFMAPIKeyDo(r *http.Request) *Response { flashW: []string{err.Error()}, } } - c.DB.SetSetting("lastfm_api_key", apiKey) - c.DB.SetSetting("lastfm_secret", secret) + if err := c.DB.SetSetting("lastfm_api_key", apiKey); err != nil { + return &Response{ + err: fmt.Sprintf("couldn't set api key: %v", err), + code: 500, + } + } + if err := c.DB.SetSetting("lastfm_secret", secret); err != nil { + return &Response{ + err: fmt.Sprintf("couldn't set secret: %v", err), + code: 500, + } + } return &Response{redirect: "/admin/home"} } diff --git a/server/ctrladmin/handlers_playlist.go b/server/ctrladmin/handlers_playlist.go index f337572a..6d7e1850 100644 --- a/server/ctrladmin/handlers_playlist.go +++ b/server/ctrladmin/handlers_playlist.go @@ -30,7 +30,7 @@ func playlistParseLine(c *Controller, path string) (int, error) { c.MusicPath, path) err := query.First(&track).Error switch { - case gorm.IsRecordNotFoundError(err): + case errors.Is(err, gorm.ErrRecordNotFound): return 0, fmt.Errorf("%v: %w", err, errPlaylistNoMatch) case err != nil: return 0, fmt.Errorf("while matching: %w", err) diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index 7e5784c8..1b544cf0 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -29,6 +29,7 @@ type Controller struct { *ctrlbase.Controller CachePath string CoverCachePath string + PodcastsPath string Jukebox *jukebox.Jukebox Scrobblers []scrobble.Scrobbler Podcasts *podcasts.Podcasts diff --git a/server/ctrlsubsonic/ctrl_test.go b/server/ctrlsubsonic/ctrl_test.go index f661c96e..3200a2be 100644 --- a/server/ctrlsubsonic/ctrl_test.go +++ b/server/ctrlsubsonic/ctrl_test.go @@ -2,6 +2,7 @@ package ctrlsubsonic import ( "context" + "io/ioutil" "log" "net/http" "net/http/httptest" @@ -16,14 +17,12 @@ import ( "go.senan.xyz/gonic/server/ctrlbase" "go.senan.xyz/gonic/server/ctrlsubsonic/params" - "go.senan.xyz/gonic/server/db" + "go.senan.xyz/gonic/server/mockfs" ) var ( - testDataDir = "testdata" - testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])") - testDBPath = path.Join(testDataDir, "db") - testController *Controller + testDataDir = "testdata" + testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])") ) type queryCase struct { @@ -53,18 +52,26 @@ func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request) return rr, req } -func runQueryCases(t *testing.T, h handlerSubsonic, cases []*queryCase) { +func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*queryCase) { + t.Helper() for _, qc := range cases { qc := qc // pin t.Run(qc.expectPath, func(t *testing.T) { t.Parallel() rr, req := makeHTTPMock(qc.params) - testController.H(h).ServeHTTP(rr, req) + contr.H(h).ServeHTTP(rr, req) body := rr.Body.String() if status := rr.Code; status != http.StatusOK { t.Fatalf("didn't give a 200\n%s", body) } goldenPath := makeGoldenPath(t.Name()) + goldenRegen := os.Getenv("GONIC_REGEN") + if goldenRegen == "*" || (goldenRegen != "" && strings.HasPrefix(t.Name(), goldenRegen)) { + _ = os.WriteFile(goldenPath, []byte(body), 0600) + t.Logf("golden file %q regenerated for %s", goldenPath, t.Name()) + t.SkipNow() + } + // read case to differ with handler result expected, err := jd.ReadJsonFile(goldenPath) if err != nil { @@ -88,13 +95,26 @@ func runQueryCases(t *testing.T, h handlerSubsonic, cases []*queryCase) { } } -func TestMain(m *testing.M) { - db, err := db.New(testDBPath) - if err != nil { - log.Fatalf("error opening database: %v\n", err) - } - testController = &Controller{ - Controller: &ctrlbase.Controller{DB: db}, +func makeController(t *testing.T) (*Controller, *mockfs.MockFS) { return makec(t, []string{""}) } +func makeControllerRoots(t *testing.T, r []string) (*Controller, *mockfs.MockFS) { return makec(t, r) } + +func makec(t *testing.T, roots []string) (*Controller, *mockfs.MockFS) { + t.Helper() + + m := mockfs.NewWithDirs(t, roots) + for _, root := range roots { + m.AddItemsPrefixWithCovers(root) } + + m.ScanAndClean() + m.ResetDates() + m.LogAlbums() + + base := &ctrlbase.Controller{DB: m.DB()} + return &Controller{Controller: base}, m +} + +func TestMain(m *testing.M) { + log.SetOutput(ioutil.Discard) os.Exit(m.Run()) } diff --git a/server/ctrlsubsonic/handlers_bookmark.go b/server/ctrlsubsonic/handlers_bookmark.go index 8595205e..85af490f 100644 --- a/server/ctrlsubsonic/handlers_bookmark.go +++ b/server/ctrlsubsonic/handlers_bookmark.go @@ -1,6 +1,7 @@ package ctrlsubsonic import ( + "errors" "net/http" "github.com/jinzhu/gorm" @@ -18,7 +19,7 @@ func (c *Controller) ServeGetBookmarks(r *http.Request) *spec.Response { Where("user_id=?", user.ID). Find(&bookmarks). Error - if gorm.IsRecordNotFoundError(err) { + if errors.Is(err, gorm.ErrRecordNotFound) { return spec.NewResponse() } sub := spec.NewResponse() diff --git a/server/ctrlsubsonic/handlers_by_folder.go b/server/ctrlsubsonic/handlers_by_folder.go index 59687cf4..080e767f 100644 --- a/server/ctrlsubsonic/handlers_by_folder.go +++ b/server/ctrlsubsonic/handlers_by_folder.go @@ -61,7 +61,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response { childrenObj := []*spec.TrackChild{} folder := &db.Album{} c.DB.First(folder, id.Value) - // ** begin start looking for child childFolders in the current dir + // start looking for child childFolders in the current dir var childFolders []*db.Album c.DB. Where("parent_id=?", id.Value). @@ -70,7 +70,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response { for _, c := range childFolders { childrenObj = append(childrenObj, spec.NewTCAlbumByFolder(c)) } - // ** begin start looking for child childTracks in the current dir + // start looking for child childTracks in the current dir var childTracks []*db.Track c.DB. Where("album_id=?", id.Value). @@ -86,7 +86,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response { } childrenObj = append(childrenObj, toAppend) } - // ** begin respond section + // respond section sub := spec.NewResponse() sub.Directory = spec.NewDirectoryByFolder(folder, childrenObj) return sub @@ -167,7 +167,7 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { } query = fmt.Sprintf("%%%s%%", strings.TrimSuffix(query, "*")) results := &spec.SearchResultTwo{} - // ** begin search "artists" + // search "artists" var artists []*db.Album c.DB. Where(` @@ -182,7 +182,7 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { results.Artists = append(results.Artists, spec.NewDirectoryByFolder(a, nil)) } - // ** begin search "albums" + // search "albums" var albums []*db.Album c.DB. Where(` @@ -196,7 +196,7 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response { for _, a := range albums { results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a)) } - // ** begin search tracks + // search tracks var tracks []*db.Track c.DB. Preload("Album"). diff --git a/server/ctrlsubsonic/handlers_by_folder_test.go b/server/ctrlsubsonic/handlers_by_folder_test.go index 0ff9a3ce..0cfe691a 100644 --- a/server/ctrlsubsonic/handlers_by_folder_test.go +++ b/server/ctrlsubsonic/handlers_by_folder_test.go @@ -8,20 +8,30 @@ import ( ) func TestGetIndexes(t *testing.T) { - runQueryCases(t, testController.ServeGetIndexes, []*queryCase{ + contr, m := makeControllerRoots(t, []string{"m-0", "m-1"}) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeGetIndexes, []*queryCase{ {url.Values{}, "no_args", false}, }) } func TestGetMusicDirectory(t *testing.T) { - runQueryCases(t, testController.ServeGetMusicDirectory, []*queryCase{ + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeGetMusicDirectory, []*queryCase{ {url.Values{"id": {"al-2"}}, "without_tracks", false}, {url.Values{"id": {"al-3"}}, "with_tracks", false}, }) } func TestGetAlbumList(t *testing.T) { - runQueryCases(t, testController.ServeGetAlbumList, []*queryCase{ + t.Parallel() + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeGetAlbumList, []*queryCase{ {url.Values{"type": {"alphabeticalByArtist"}}, "alpha_artist", false}, {url.Values{"type": {"alphabeticalByName"}}, "alpha_name", false}, {url.Values{"type": {"newest"}}, "newest", false}, @@ -30,9 +40,13 @@ func TestGetAlbumList(t *testing.T) { } func TestSearchTwo(t *testing.T) { - runQueryCases(t, testController.ServeSearchTwo, []*queryCase{ - {url.Values{"query": {"13"}}, "q_13", false}, - {url.Values{"query": {"ani"}}, "q_ani", false}, - {url.Values{"query": {"cert"}}, "q_cert", false}, + t.Parallel() + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeSearchTwo, []*queryCase{ + {url.Values{"query": {"art"}}, "q_art", false}, + {url.Values{"query": {"alb"}}, "q_alb", false}, + {url.Values{"query": {"tra"}}, "q_tra", false}, }) } diff --git a/server/ctrlsubsonic/handlers_by_tags.go b/server/ctrlsubsonic/handlers_by_tags.go index 6424b9ff..9c19bc65 100644 --- a/server/ctrlsubsonic/handlers_by_tags.go +++ b/server/ctrlsubsonic/handlers_by_tags.go @@ -1,6 +1,7 @@ package ctrlsubsonic import ( + "errors" "fmt" "net/http" "strings" @@ -88,7 +89,7 @@ func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response { }). First(album, id.Value). Error - if gorm.IsRecordNotFoundError(err) { + if errors.Is(err, gorm.ErrRecordNotFound) { return spec.NewError(10, "couldn't find an album with that id") } sub := spec.NewResponse() @@ -174,7 +175,7 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { query = fmt.Sprintf("%%%s%%", strings.TrimSuffix(query, "*")) results := &spec.SearchResultThree{} - // ** begin search "artists" + // search "artists" var artists []*db.Artist c.DB. Where("name LIKE ? OR name_u_dec LIKE ?", @@ -186,7 +187,7 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { results.Artists = append(results.Artists, spec.NewArtistByTags(a)) } - // ** begin search "albums" + // search "albums" var albums []*db.Album c.DB. Preload("TagArtist"). @@ -199,7 +200,7 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response { results.Albums = append(results.Albums, spec.NewAlbumByTags(a, a.TagArtist)) } - // ** begin search tracks + // search tracks var tracks []*db.Track c.DB. Preload("Album"). @@ -223,7 +224,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { if err != nil { return spec.NewError(10, "please provide an `id` parameter") } - apiKey := c.DB.GetSetting("lastfm_api_key") + apiKey, _ := c.DB.GetSetting("lastfm_api_key") if apiKey == "" { sub := spec.NewResponse() sub.ArtistInfoTwo = &spec.ArtistInfo{} @@ -234,7 +235,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { Where("id=?", id.Value). Find(artist). Error - if gorm.IsRecordNotFoundError(err) { + if errors.Is(err, gorm.ErrRecordNotFound) { return spec.NewError(70, "artist with id `%s` not found", id) } info, err := lastfm.ArtistGetInfo(apiKey, artist) @@ -271,7 +272,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response { Group("artists.id"). Find(artist). Error - if gorm.IsRecordNotFoundError(err) && !inclNotPresent { + if errors.Is(err, gorm.ErrRecordNotFound) && !inclNotPresent { continue } similar := &spec.SimilarArtist{ diff --git a/server/ctrlsubsonic/handlers_by_tags_test.go b/server/ctrlsubsonic/handlers_by_tags_test.go index 326586ec..55f9ed9c 100644 --- a/server/ctrlsubsonic/handlers_by_tags_test.go +++ b/server/ctrlsubsonic/handlers_by_tags_test.go @@ -6,13 +6,21 @@ import ( ) func TestGetArtists(t *testing.T) { - runQueryCases(t, testController.ServeGetArtists, []*queryCase{ + t.Parallel() + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeGetArtists, []*queryCase{ {url.Values{}, "no_args", false}, }) } func TestGetArtist(t *testing.T) { - runQueryCases(t, testController.ServeGetArtist, []*queryCase{ + t.Parallel() + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeGetArtist, []*queryCase{ {url.Values{"id": {"ar-1"}}, "id_one", false}, {url.Values{"id": {"ar-2"}}, "id_two", false}, {url.Values{"id": {"ar-3"}}, "id_three", false}, @@ -20,14 +28,22 @@ func TestGetArtist(t *testing.T) { } func TestGetAlbum(t *testing.T) { - runQueryCases(t, testController.ServeGetAlbum, []*queryCase{ + t.Parallel() + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeGetAlbum, []*queryCase{ {url.Values{"id": {"al-2"}}, "without_cover", false}, {url.Values{"id": {"al-3"}}, "with_cover", false}, }) } func TestGetAlbumListTwo(t *testing.T) { - runQueryCases(t, testController.ServeGetAlbumListTwo, []*queryCase{ + t.Parallel() + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeGetAlbumListTwo, []*queryCase{ {url.Values{"type": {"alphabeticalByArtist"}}, "alpha_artist", false}, {url.Values{"type": {"alphabeticalByName"}}, "alpha_name", false}, {url.Values{"type": {"newest"}}, "newest", false}, @@ -36,9 +52,13 @@ func TestGetAlbumListTwo(t *testing.T) { } func TestSearchThree(t *testing.T) { - runQueryCases(t, testController.ServeSearchThree, []*queryCase{ - {url.Values{"query": {"13"}}, "q_13", false}, - {url.Values{"query": {"ani"}}, "q_ani", false}, - {url.Values{"query": {"cert"}}, "q_cert", false}, + t.Parallel() + contr, m := makeController(t) + defer m.CleanUp() + + runQueryCases(t, contr, contr.ServeSearchThree, []*queryCase{ + {url.Values{"query": {"art"}}, "q_art", false}, + {url.Values{"query": {"alb"}}, "q_alb", false}, + {url.Values{"query": {"tit"}}, "q_tra", false}, }) } diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 167e61dd..0f9a3e65 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -1,6 +1,7 @@ package ctrlsubsonic import ( + "errors" "log" "net/http" "time" @@ -79,7 +80,7 @@ func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response { func (c *Controller) ServeStartScan(r *http.Request) *spec.Response { go func() { - if err := c.Scanner.Start(scanner.ScanOptions{}); err != nil { + if err := c.Scanner.ScanAndClean(scanner.ScanOptions{}); err != nil { log.Printf("error while scanning: %v\n", err) } }() @@ -93,7 +94,7 @@ func (c *Controller) ServeGetScanStatus(r *http.Request) *spec.Response { Count(&trackCount) sub := spec.NewResponse() sub.ScanStatus = &spec.ScanStatus{ - Scanning: scanner.IsScanning(), + Scanning: c.Scanner.IsScanning(), Count: trackCount, } return sub @@ -126,7 +127,7 @@ func (c *Controller) ServeGetPlayQueue(r *http.Request) *spec.Response { Where("user_id=?", user.ID). Find(&queue). Error - if gorm.IsRecordNotFoundError(err) { + if errors.Is(err, gorm.ErrRecordNotFound) { return spec.NewResponse() } sub := spec.NewResponse() @@ -185,7 +186,7 @@ func (c *Controller) ServeGetSong(r *http.Request) *spec.Response { Preload("Album"). First(track). Error - if gorm.IsRecordNotFoundError(err) { + if errors.Is(err, gorm.ErrRecordNotFound) { return spec.NewError(10, "couldn't find a track with that id") } sub := spec.NewResponse() diff --git a/server/ctrlsubsonic/handlers_playlist.go b/server/ctrlsubsonic/handlers_playlist.go index 3b31ea24..5ef22a2b 100644 --- a/server/ctrlsubsonic/handlers_playlist.go +++ b/server/ctrlsubsonic/handlers_playlist.go @@ -1,6 +1,7 @@ package ctrlsubsonic import ( + "errors" "log" "net/http" "sort" @@ -33,7 +34,7 @@ func playlistRender(c *Controller, playlist *db.Playlist) *spec.Playlist { Preload("Album"). Find(&track). Error - if gorm.IsRecordNotFoundError(err) { + if errors.Is(err, gorm.ErrRecordNotFound) { log.Printf("wasn't able to find track with id %d", id) continue } @@ -68,7 +69,7 @@ func (c *Controller) ServeGetPlaylist(r *http.Request) *spec.Response { Where("id=?", playlistID). Find(&playlist). Error - if gorm.IsRecordNotFoundError(err) { + if errors.Is(err, gorm.ErrRecordNotFound) { return spec.NewError(70, "playlist with id `%d` not found", playlistID) } sub := spec.NewResponse() diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 776a0007..73a1d3ee 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -173,7 +173,7 @@ func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *s _, err = os.Stat(cachePath) switch { case os.IsNotExist(err): - coverPath, err := coverGetPath(c.DB, c.MusicPath, c.Podcasts.PodcastBasePath, id) + coverPath, err := coverGetPath(c.DB, c.PodcastsPath, id) if err != nil { return spec.NewError(10, "couldn't find cover `%s`: %v", id, err) } @@ -208,7 +208,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R case specid.PodcastEpisode: podcast, err := streamGetPodcast(c.DB, id.Value) audioFile = podcast - audioPath = path.Join(c.Podcasts.PodcastBasePath, podcast.Path) + audioPath = path.Join(c.PodcastsPath, podcast.Path) if err != nil { return spec.NewError(70, "podcast with id `%s` was not found", id) } @@ -285,7 +285,7 @@ func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec case specid.PodcastEpisode: podcast, err := streamGetPodcast(c.DB, id.Value) audioFile = podcast - filePath = path.Join(c.Podcasts.PodcastBasePath, podcast.Path) + filePath = path.Join(c.PodcastsPath, podcast.Path) if err != nil { return spec.NewError(70, "podcast with id `%s` was not found", id) } diff --git a/server/ctrlsubsonic/testdata/db b/server/ctrlsubsonic/testdata/db deleted file mode 100644 index 7aad42f3acc4e14393ad2def698dbe70d85d2803..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 192512 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 z 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 0 { + return itemErrs } - log.Printf("finished scan in %s, +%d/%d tracks (%d err)\n", - durSince(start), - s.seenTracksNew, - len(s.seenTracks), - errCount, - ) - if err := s.cleanTracks(); err != nil { + return nil +} + +func (s *Scanner) clean(c *collected) error { + if err := s.cleanTracks(c.seenTracks); err != nil { return fmt.Errorf("clean tracks: %w", err) } - if err := s.cleanAlbums(); err != nil { + if err := s.cleanAlbums(c.seenAlbums); err != nil { return fmt.Errorf("clean albums: %w", err) } if err := s.cleanArtists(); err != nil { @@ -244,222 +134,165 @@ func (s *Scanner) Start(opts ScanOptions) error { if err := s.cleanGenres(); err != nil { return fmt.Errorf("clean genres: %w", err) } - - // finish up - strNow := strconv.FormatInt(time.Now().Unix(), 10) - s.db.SetSetting("last_scan_time", strNow) return nil } -// items are passed to the handle*() functions -type item struct { - fullPath string - relPath string - directory string - filename string - stat os.FileInfo -} +func (s *Scanner) callback(c *collected, isFull bool, rootAbsPath string, itemAbsPath string) error { + if rootAbsPath == itemAbsPath { + return nil + } -func isCover(filename string) bool { - filename = strings.ToLower(filename) - known := map[string]struct{}{ - "cover.png": {}, - "cover.jpg": {}, - "cover.jpeg": {}, - "folder.png": {}, - "folder.jpg": {}, - "folder.jpeg": {}, - "album.png": {}, - "album.jpg": {}, - "album.jpeg": {}, - "albumart.png": {}, - "albumart.jpg": {}, - "albumart.jpeg": {}, - "front.png": {}, - "front.jpg": {}, - "front.jpeg": {}, - } - _, ok := known[filename] - return ok -} + relpath, _ := filepath.Rel(rootAbsPath, itemAbsPath) + gs, err := godirwalk.NewScanner(itemAbsPath) + if err != nil { + return err + } + + var tracks []string + var cover string + for gs.Scan() { + if isCover(gs.Name()) { + cover = gs.Name() + continue + } + if _, ok := mime.FromExtension(ext(gs.Name())); ok { + tracks = append(tracks, gs.Name()) + continue + } + } -// ## begin callbacks -// ## begin callbacks -// ## begin callbacks + tx := s.db.Begin() + defer tx.Commit() -func (s *Scanner) callbackItem(fullPath string, info *godirwalk.Dirent) error { - stat, err := os.Stat(fullPath) - if err != nil { - return fmt.Errorf("%w: %v", ErrStatingItem, err) + pdir, pbasename := filepath.Split(filepath.Dir(relpath)) + parent := &db.Album{} + if err := tx.Where(db.Album{RootDir: rootAbsPath, LeftPath: pdir, RightPath: pbasename}).FirstOrCreate(parent).Error; err != nil { + return fmt.Errorf("first or create parent: %w", err) } - relPath, err := filepath.Rel(s.musicPath, fullPath) - if err != nil { - return fmt.Errorf("getting relative path: %w", err) + + c.seenAlbums[parent.ID] = struct{}{} + + dir, basename := filepath.Split(relpath) + album := &db.Album{} + if err := tx.Where(db.Album{RootDir: rootAbsPath, LeftPath: dir, RightPath: basename}).First(album).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("find album: %w", err) } - directory, filename := path.Split(relPath) - it := &item{ - fullPath: fullPath, - relPath: relPath, - directory: directory, - filename: filename, - stat: stat, + + if err := s.populateAlbumBasics(tx, rootAbsPath, parent, album, dir, basename, cover); err != nil { + return fmt.Errorf("populate album basics: %w", err) } - isDir, err := info.IsDirOrSymlinkToDir() - if err != nil { - return fmt.Errorf("stating link to dir: %w", err) + + c.seenAlbums[album.ID] = struct{}{} + + sort.Strings(tracks) + for i, basename := range tracks { + abspath := filepath.Join(itemAbsPath, basename) + if err := s.populateTrackAndAlbumArtists(tx, c, i, album, basename, abspath, isFull); err != nil { + return fmt.Errorf("process %q: %w", "", err) + } } - if isDir { - return s.handleAlbum(it) + + return nil +} + +func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *collected, i int, album *db.Album, basename string, abspath string, isFull bool) error { + track := &db.Track{AlbumID: album.ID, Filename: filepath.Base(basename)} + if err := tx.Where(track).First(track).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("query track: %w", err) } - if isCover(filename) { - s.curCover = filename - return nil + + c.seenTracks[track.ID] = struct{}{} + + stat, err := os.Stat(abspath) + if err != nil { + return fmt.Errorf("stating %q: %w", basename, err) } - ext := path.Ext(filename) - if ext == "" { + if !isFull && stat.ModTime().Before(track.UpdatedAt) { return nil } - if _, ok := mime.FromExtension(ext[1:]); ok { - return s.handleTrack(it) + + trags, err := s.tagger.Read(abspath) + if err != nil { + return fmt.Errorf("%v: %w", err, ErrReadingTags) } - return nil -} -func (s *Scanner) callbackPost(fullPath string, info *godirwalk.Dirent) error { - defer func() { - s.curCover = "" - }() - if s.trTxOpen { - s.trTx.Commit() - s.trTxOpen = false - } - // begin taking the current album off the stack and add it's - // parent, cover that we found, etc. - album := s.curAlbums.Pop() - if album.Cover == s.curCover && album.ParentID != 0 { - return nil + artistName := trags.SomeAlbumArtist() + albumArtist, err := s.populateAlbumArtist(tx, artistName) + if err != nil { + return fmt.Errorf("populate artist: %w", err) } - album.ParentID = s.curAlbums.PeekID() - album.Cover = s.curCover - if err := s.db.Save(album).Error; err != nil { - return fmt.Errorf("writing albums table: %w", err) + + if err := s.populateTrack(tx, album, albumArtist, track, trags, basename, int(stat.Size())); err != nil { + return fmt.Errorf("process %q: %w", basename, err) } - // we only log changed albums - log.Printf("processed folder `%s`\n", - path.Join(album.LeftPath, album.RightPath)) - return nil -} -// ## begin handlers -// ## begin handlers -// ## begin handlers + c.seenTracks[track.ID] = struct{}{} + c.seenTracksNew++ -func (s *Scanner) itemUnchanged(statModTime, updatedInDB time.Time) bool { - if s.isFull { - return false + genreNames := strings.Split(trags.SomeGenre(), s.genreSplit) + genreIDs, err := s.populateGenres(tx, track, genreNames) + if err != nil { + return fmt.Errorf("populate genres: %w", err) } - return statModTime.Before(updatedInDB) -} -func (s *Scanner) handleAlbum(it *item) error { - if s.trTxOpen { - // a transaction still being open when we handle an album can - // happen if there is a album that contains /both/ tracks and - // sub albums - s.trTx.Commit() - s.trTxOpen = false + if err := s.populateTrackGenres(tx, track, genreIDs); err != nil { + return fmt.Errorf("propulate track genres: %w", err) } - album := &db.Album{} - defer func() { - // album's id will come from early return - // or save at the end - s.seenAlbums[album.ID] = struct{}{} - s.curAlbums.Push(album) - }() - err := s.db. - Where(db.Album{ - LeftPath: it.directory, - RightPath: it.filename, - }). - First(album). - Error - if !gorm.IsRecordNotFoundError(err) && - s.itemUnchanged(it.stat.ModTime(), album.UpdatedAt) { - // we found the record but it hasn't changed + + // metadata for the album table comes only from the the first track's tags + if i > 0 { return nil } - album.LeftPath = it.directory - album.RightPath = it.filename - album.RightPathUDec = decoded(it.filename) - album.ModifiedAt = it.stat.ModTime() - if err := s.db.Save(album).Error; err != nil { - return fmt.Errorf("writing albums table: %w", err) - } - return nil -} -func (s *Scanner) handleTrack(it *item) error { - if !s.trTxOpen { - s.trTx = s.db.Begin() - s.trTxOpen = true - } - - // init empty track and mark its ID (from lookup or save) - // for later cleanup later - var track db.Track - defer func() { - s.seenTracks[track.ID] = struct{}{} - }() - - album := s.curAlbums.Peek() - err := s.trTx. - Select("id, updated_at"). - Where(db.Track{ - AlbumID: album.ID, - Filename: it.filename, - }). - First(&track). - Error - if !gorm.IsRecordNotFoundError(err) && - s.itemUnchanged(it.stat.ModTime(), track.UpdatedAt) { - // we found the record but it hasn't changed - return nil + if err := populateAlbum(tx, album, albumArtist, trags, stat.ModTime()); err != nil { + return fmt.Errorf("propulate album: %w", err) } - trags, err := tags.New(it.fullPath) - if err != nil { - return ErrReadingTags + if err := s.populateAlbumGenres(tx, album, genreIDs); err != nil { + return fmt.Errorf("populate album genres: %w", err) } - genreIDs, err := s.populateGenres(&track, trags) - if err != nil { - return fmt.Errorf("populate genres: %w", err) + return nil +} + +func populateAlbum(tx *db.DB, album *db.Album, albumArtist *db.Artist, trags tags.Parser, modTime time.Time) error { + albumName := trags.SomeAlbum() + album.TagTitle = albumName + album.TagTitleUDec = decoded(albumName) + album.TagBrainzID = trags.AlbumBrainzID() + album.TagYear = trags.Year() + album.TagArtistID = albumArtist.ID + album.ModifiedAt = modTime + + if err := tx.Save(&album).Error; err != nil { + return fmt.Errorf("saving album: %w", err) } - // create album and album artist records for first track in album - if album.TagTitle == "" { - albumArtist, err := s.populateAlbumArtist(trags) - if err != nil { - return fmt.Errorf("populate artist: %w", err) - } + return nil +} - albumName := trags.SomeAlbum() - album.TagTitle = albumName - album.TagTitleUDec = decoded(albumName) - album.TagBrainzID = trags.AlbumBrainzID() - album.TagYear = trags.Year() - album.TagArtistID = albumArtist.ID +func (s *Scanner) populateAlbumBasics(tx *db.DB, rootAbsPath string, parent, album *db.Album, dir, basename string, cover string) error { + album.RootDir = rootAbsPath + album.LeftPath = dir + album.RightPath = basename + album.Cover = cover + album.RightPathUDec = decoded(basename) + album.ParentID = parent.ID - if err := s.populateAlbumGenres(album, genreIDs); err != nil { - return fmt.Errorf("populate album genres: %w", err) - } + if err := tx.Save(&album).Error; err != nil { + return fmt.Errorf("saving album: %w", err) } - track.Filename = it.filename - track.FilenameUDec = decoded(it.filename) - track.Size = int(it.stat.Size()) + return nil +} + +func (s *Scanner) populateTrack(tx *db.DB, album *db.Album, albumArtist *db.Artist, track *db.Track, trags tags.Parser, abspath string, size int) error { + basename := filepath.Base(abspath) + track.Filename = basename + track.FilenameUDec = decoded(basename) + track.Size = size track.AlbumID = album.ID - track.ArtistID = album.TagArtistID + track.ArtistID = albumArtist.ID track.TagTitle = trags.Title() track.TagTitleUDec = decoded(trags.Title()) @@ -471,88 +304,197 @@ func (s *Scanner) handleTrack(it *item) error { track.Length = trags.Length() // these two should be calculated track.Bitrate = trags.Bitrate() // ...from the file instead of tags - if err := s.trTx.Save(&track).Error; err != nil { - return fmt.Errorf("writing track table: %w", err) - } - s.seenTracksNew++ - - if err := s.populateTrackGenres(&track, genreIDs); err != nil { - return fmt.Errorf("populating track genres : %w", err) + if err := tx.Save(&track).Error; err != nil { + return fmt.Errorf("saving track: %w", err) } return nil } -func (s *Scanner) populateAlbumArtist(trags *tags.Tags) (*db.Artist, error) { +func (s *Scanner) populateAlbumArtist(tx *db.DB, artistName string) (*db.Artist, error) { var artist db.Artist - artistName := trags.SomeAlbumArtist() - err := s.trTx. - Where("name=?", artistName). - Assign(db.Artist{ - Name: artistName, - NameUDec: decoded(artistName), - }). - FirstOrCreate(&artist). - Error - if err != nil { + update := db.Artist{ + Name: artistName, + NameUDec: decoded(artistName), + } + if err := tx.Where("name=?", artistName).Assign(update).FirstOrCreate(&artist).Error; err != nil { return nil, fmt.Errorf("find or create artist: %w", err) } return &artist, nil } -func (s *Scanner) populateGenres(track *db.Track, trags *tags.Tags) ([]int, error) { - var genreIDs []int - genreNames := strings.Split(trags.SomeGenre(), s.genreSplit) - for _, genreName := range genreNames { - genre := &db.Genre{} - q := s.trTx.FirstOrCreate(genre, db.Genre{ - Name: genreName, - }) - if err := q.Error; err != nil { - return nil, err +func (s *Scanner) populateGenres(tx *db.DB, track *db.Track, names []string) ([]int, error) { + var filteredNames []string + for _, name := range names { + if clean := strings.TrimSpace(name); clean != "" { + filteredNames = append(filteredNames, clean) + } + } + if len(filteredNames) == 0 { + return []int{}, nil + } + var ids []int + for _, name := range filteredNames { + var genre db.Genre + if err := tx.FirstOrCreate(&genre, db.Genre{Name: name}).Error; err != nil { + return nil, fmt.Errorf("find or create genre: %w", err) } - genreIDs = append(genreIDs, genre.ID) + ids = append(ids, genre.ID) } - return genreIDs, nil + return ids, nil } -func (s *Scanner) populateTrackGenres(track *db.Track, genreIDs []int) error { - err := s.trTx. - Where("track_id=?", track.ID). - Delete(db.TrackGenre{}). - Error - if err != nil { +func (s *Scanner) populateTrackGenres(tx *db.DB, track *db.Track, genreIDs []int) error { + if err := tx.Where("track_id=?", track.ID).Delete(db.TrackGenre{}).Error; err != nil { return fmt.Errorf("delete old track genre records: %w", err) } - err = s.trTx.InsertBulkLeftMany( - "track_genres", - []string{"track_id", "genre_id"}, - track.ID, - genreIDs, - ) - if err != nil { + if err := tx.InsertBulkLeftMany("track_genres", []string{"track_id", "genre_id"}, track.ID, genreIDs); err != nil { return fmt.Errorf("insert bulk track genres: %w", err) } return nil } -func (s *Scanner) populateAlbumGenres(album *db.Album, genreIDs []int) error { - err := s.trTx. - Where("album_id=?", album.ID). - Delete(db.AlbumGenre{}). +func (s *Scanner) populateAlbumGenres(tx *db.DB, album *db.Album, genreIDs []int) error { + if err := tx.Where("album_id=?", album.ID).Delete(db.AlbumGenre{}).Error; err != nil { + return fmt.Errorf("delete old album genre records: %w", err) + } + + if err := tx.InsertBulkLeftMany("album_genres", []string{"album_id", "genre_id"}, album.ID, genreIDs); err != nil { + return fmt.Errorf("insert bulk album genres: %w", err) + } + return nil +} + +func (s *Scanner) cleanTracks(seenTracks map[int]struct{}) error { + start := time.Now() + var previous []int + var missing []int64 + err := s.db. + Model(&db.Track{}). + Pluck("id", &previous). Error if err != nil { - return fmt.Errorf("delete old album genre records: %w", err) + return fmt.Errorf("plucking ids: %w", err) + } + for _, prev := range previous { + if _, ok := seenTracks[prev]; !ok { + missing = append(missing, int64(prev)) + } } - err = s.trTx.InsertBulkLeftMany( - "album_genres", - []string{"album_id", "genre_id"}, - album.ID, - genreIDs, - ) + err = s.db.TransactionChunked(missing, func(tx *gorm.DB, chunk []int64) error { + return tx.Where(chunk).Delete(&db.Track{}).Error + }) if err != nil { - return fmt.Errorf("insert bulk album genres: %w", err) + return err } + log.Printf("finished clean tracks in %s, %d removed", durSince(start), len(missing)) return nil } + +func (s *Scanner) cleanAlbums(seenAlbums map[int]struct{}) error { + start := time.Now() + var previous []int + var missing []int64 + err := s.db. + Model(&db.Album{}). + Pluck("id", &previous). + Error + if err != nil { + return fmt.Errorf("plucking ids: %w", err) + } + for _, prev := range previous { + if _, ok := seenAlbums[prev]; !ok { + missing = append(missing, int64(prev)) + } + } + err = s.db.TransactionChunked(missing, func(tx *gorm.DB, chunk []int64) error { + return tx.Where(chunk).Delete(&db.Album{}).Error + }) + if err != nil { + return err + } + log.Printf("finished clean albums in %s, %d removed", durSince(start), len(missing)) + return nil +} + +func (s *Scanner) cleanArtists() error { + start := time.Now() + sub := s.db. + Select("artists.id"). + Model(&db.Artist{}). + Joins("LEFT JOIN albums ON albums.tag_artist_id=artists.id"). + Where("albums.id IS NULL"). + SubQuery() + q := s.db. + Where("artists.id IN ?", sub). + Delete(&db.Artist{}) + if err := q.Error; err != nil { + return err + } + log.Printf("finished clean artists in %s, %d removed", durSince(start), q.RowsAffected) + return nil +} + +func (s *Scanner) cleanGenres() error { + start := time.Now() + subTrack := s.db. + Select("genres.id"). + Model(&db.Genre{}). + Joins("LEFT JOIN track_genres ON track_genres.genre_id=genres.id"). + Where("track_genres.genre_id IS NULL"). + SubQuery() + subAlbum := s.db. + Select("genres.id"). + Model(&db.Genre{}). + Joins("LEFT JOIN album_genres ON album_genres.genre_id=genres.id"). + Where("album_genres.genre_id IS NULL"). + SubQuery() + q := s.db. + Where("genres.id IN ? AND genres.id IN ?", subTrack, subAlbum). + Delete(&db.Genre{}) + log.Printf("finished clean genres in %s, %d removed", durSince(start), q.RowsAffected) + return nil +} + +func ext(name string) string { + ext := filepath.Ext(name) + if len(ext) == 0 { + return "" + } + return ext[1:] +} + +func isCover(name string) bool { + switch path := strings.ToLower(name); path { + case + "cover.png", "cover.jpg", "cover.jpeg", + "folder.png", "folder.jpg", "folder.jpeg", + "album.png", "album.jpg", "album.jpeg", + "albumart.png", "albumart.jpg", "albumart.jpeg", + "front.png", "front.jpg", "front.jpeg": + return true + default: + return false + } +} + +// decoded converts a string to it's latin equivalent. +// it will be used by the model's *UDec fields, and is only set if it +// differs from the original. the fields are used for searching. +func decoded(in string) string { + if u := unidecode.Unidecode(in); u != in { + return u + } + return "" +} + +func durSince(t time.Time) time.Duration { + return time.Since(t).Truncate(10 * time.Microsecond) +} + +type collected struct { + seenTracks map[int]struct{} + seenAlbums map[int]struct{} + seenTracksNew int +} diff --git a/server/scanner/scanner_test.go b/server/scanner/scanner_test.go index 64d90af0..6cac4727 100644 --- a/server/scanner/scanner_test.go +++ b/server/scanner/scanner_test.go @@ -1,4 +1,4 @@ -package scanner +package scanner_test import ( "io/ioutil" @@ -6,62 +6,319 @@ import ( "os" "testing" + "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/sqlite" + "github.com/matryer/is" "go.senan.xyz/gonic/server/db" + "go.senan.xyz/gonic/server/mockfs" ) -var testScanner *Scanner +func TestMain(m *testing.M) { + log.SetOutput(ioutil.Discard) + os.Exit(m.Run()) +} + +func TestTableCounts(t *testing.T) { + t.Parallel() + is := is.NewRelaxed(t) + m := mockfs.New(t) + defer m.CleanUp() -func resetTables(db *db.DB) { - tx := db.Begin() - defer tx.Commit() - tx.Exec("delete from tracks") - tx.Exec("delete from artists") - tx.Exec("delete from albums") + m.AddItems() + m.ScanAndClean() + + var tracks int + is.NoErr(m.DB().Model(&db.Track{}).Count(&tracks).Error) // not all tracks + is.Equal(tracks, 3*3*3) // not all tracks + + var albums int + is.NoErr(m.DB().Model(&db.Album{}).Count(&albums).Error) // not all albums + is.Equal(albums, 13) // not all albums + + var artists int + is.NoErr(m.DB().Model(&db.Artist{}).Count(&artists).Error) // not all artists + is.Equal(artists, 3) // not all artists } -func resetTablesPause(db *db.DB, b *testing.B) { - b.StopTimer() - defer b.StartTimer() - resetTables(db) +func TestParentID(t *testing.T) { + t.Parallel() + is := is.New(t) + m := mockfs.New(t) + defer m.CleanUp() + + m.AddItems() + m.ScanAndClean() + + var nullParentAlbums []*db.Album + is.NoErr(m.DB().Where("parent_id IS NULL").Find(&nullParentAlbums).Error) // one parent_id=NULL which is root folder + is.Equal(len(nullParentAlbums), 1) // one parent_id=NULL which is root folder + is.Equal(nullParentAlbums[0].LeftPath, "") + is.Equal(nullParentAlbums[0].RightPath, ".") + + is.Equal(m.DB().Where("id=parent_id").Find(&db.Album{}).Error, gorm.ErrRecordNotFound) // no self-referencing albums + + var album db.Album + var parent db.Album + is.NoErr(m.DB().Find(&album, "left_path=? AND right_path=?", "artist-0/", "album-0").Error) // album has parent ID + is.NoErr(m.DB().Find(&parent, "right_path=?", "artist-0").Error) // album has parent ID + is.Equal(album.ParentID, parent.ID) // album has parent ID } -func BenchmarkScanFresh(b *testing.B) { - for n := 0; n < b.N; n++ { - resetTablesPause(testScanner.db, b) - _ = testScanner.Start(ScanOptions{}) - } +func TestUpdatedCover(t *testing.T) { + t.Parallel() + is := is.NewRelaxed(t) + m := mockfs.New(t) + defer m.CleanUp() + + m.AddItems() + m.ScanAndClean() + m.AddCover("artist-0/album-0/cover.jpg") + m.ScanAndClean() + + var album db.Album + is.NoErr(m.DB().Where("left_path=? AND right_path=?", "artist-0/", "album-0").Find(&album).Error) // album has cover + is.Equal(album.Cover, "cover.jpg") // album has cover } -func BenchmarkScanIncremental(b *testing.B) { - // do a full scan and reset - _ = testScanner.Start(ScanOptions{}) - b.ResetTimer() - // do the inc scans - for n := 0; n < b.N; n++ { - _ = testScanner.Start(ScanOptions{}) +func TestCoverBeforeTracks(t *testing.T) { + t.Parallel() + is := is.New(t) + m := mockfs.New(t) + defer m.CleanUp() + + m.AddCover("artist-2/album-2/cover.jpg") + m.ScanAndClean() + m.AddItems() + m.ScanAndClean() + + var album db.Album + is.NoErr(m.DB().Preload("TagArtist").Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error) // album has cover + is.Equal(album.Cover, "cover.jpg") // album has cover + is.Equal(album.TagArtist.Name, "artist-2") // album artist + + var tracks []*db.Track + is.NoErr(m.DB().Where("album_id=?", album.ID).Find(&tracks).Error) // album has tracks + is.Equal(len(tracks), 3) // album has tracks +} + +func TestUpdatedTags(t *testing.T) { + t.Parallel() + is := is.New(t) + m := mockfs.New(t) + defer m.CleanUp() + + m.AddTrack("artist-10/album-10/track-10.flac") + m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) { + tags.RawArtist = "artist" + tags.RawAlbumArtist = "album-artist" + tags.RawAlbum = "album" + tags.RawTitle = "title" + }) + + m.ScanAndClean() + + var track db.Track + is.NoErr(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&track).Error) // track has tags + is.Equal(track.TagTrackArtist, "artist") // track has tags + is.Equal(track.Artist.Name, "album-artist") // track has tags + is.Equal(track.Album.TagTitle, "album") // track has tags + is.Equal(track.TagTitle, "title") // track has tags + + m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) { + tags.RawArtist = "artist-upd" + tags.RawAlbumArtist = "album-artist-upd" + tags.RawAlbum = "album-upd" + tags.RawTitle = "title-upd" + }) + + m.ScanAndClean() + + var updated db.Track + is.NoErr(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&updated).Error) // updated has tags + is.Equal(updated.ID, track.ID) // updated has tags + is.Equal(updated.TagTrackArtist, "artist-upd") // updated has tags + is.Equal(updated.Artist.Name, "album-artist-upd") // updated has tags + is.Equal(updated.Album.TagTitle, "album-upd") // updated has tags + is.Equal(updated.TagTitle, "title-upd") // updated has tags +} + +func TestDelete(t *testing.T) { + t.Parallel() + is := is.NewRelaxed(t) + m := mockfs.New(t) + defer m.CleanUp() + + m.AddItems() + m.ScanAndClean() + + var album db.Album + is.NoErr(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error) // album exists + + m.RemoveAll("artist-2/album-2") + m.ScanAndClean() + + is.Equal(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error, gorm.ErrRecordNotFound) // album doesn't exist +} + +func TestGenres(t *testing.T) { + t.Parallel() + is := is.New(t) + m := mockfs.New(t) + defer m.CleanUp() + + albumGenre := func(artist, album, genre string) error { + return m.DB(). + Where("albums.left_path=? AND albums.right_path=? AND genres.name=?", artist, album, genre). + Joins("JOIN albums ON albums.id=album_genres.album_id"). + Joins("JOIN genres ON genres.id=album_genres.genre_id"). + Find(&db.AlbumGenre{}). + Error } + isAlbumGenre := func(artist, album, genreName string) { + is.Helper() + is.NoErr(albumGenre(artist, album, genreName)) + } + isAlbumGenreMissing := func(artist, album, genreName string) { + is.Helper() + is.Equal(albumGenre(artist, album, genreName), gorm.ErrRecordNotFound) + } + + trackGenre := func(artist, album, filename, genreName string) error { + return m.DB(). + Where("albums.left_path=? AND albums.right_path=? AND tracks.filename=? AND genres.name=?", artist, album, filename, genreName). + Joins("JOIN tracks ON tracks.id=track_genres.track_id"). + Joins("JOIN genres ON genres.id=track_genres.genre_id"). + Joins("JOIN albums ON albums.id=tracks.album_id"). + Find(&db.TrackGenre{}). + Error + } + isTrackGenre := func(artist, album, filename, genreName string) { + is.Helper() + is.NoErr(trackGenre(artist, album, filename, genreName)) + } + isTrackGenreMissing := func(artist, album, filename, genreName string) { + is.Helper() + is.Equal(trackGenre(artist, album, filename, genreName), gorm.ErrRecordNotFound) + } + + genre := func(genre string) error { + return m.DB().Where("name=?", genre).Find(&db.Genre{}).Error + } + isGenre := func(genreName string) { + is.Helper() + is.NoErr(genre(genreName)) + } + isGenreMissing := func(genreName string) { + is.Helper() + is.Equal(genre(genreName), gorm.ErrRecordNotFound) + } + + m.AddItems() + m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-a;genre-b" }) + m.SetTags("artist-0/album-0/track-1.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-c;genre-d" }) + m.SetTags("artist-1/album-2/track-0.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-e;genre-f" }) + m.SetTags("artist-1/album-2/track-1.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-g;genre-h" }) + m.ScanAndClean() + + isGenre("genre-a") // genre exists + isGenre("genre-b") // genre exists + isGenre("genre-c") // genre exists + isGenre("genre-d") // genre exists + + isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-a") // track genre exists + isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-b") // track genre exists + isTrackGenre("artist-0/", "album-0", "track-1.flac", "genre-c") // track genre exists + isTrackGenre("artist-0/", "album-0", "track-1.flac", "genre-d") // track genre exists + isTrackGenre("artist-1/", "album-2", "track-0.flac", "genre-e") // track genre exists + isTrackGenre("artist-1/", "album-2", "track-0.flac", "genre-f") // track genre exists + isTrackGenre("artist-1/", "album-2", "track-1.flac", "genre-g") // track genre exists + isTrackGenre("artist-1/", "album-2", "track-1.flac", "genre-h") // track genre exists + + isAlbumGenre("artist-0/", "album-0", "genre-a") // album genre exists + isAlbumGenre("artist-0/", "album-0", "genre-b") // album genre exists + + m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-aa;genre-bb" }) + m.ScanAndClean() + + isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-aa") // updated track genre exists + isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-bb") // updated track genre exists + isTrackGenreMissing("artist-0/", "album-0", "track-0.flac", "genre-a") // old track genre missing + isTrackGenreMissing("artist-0/", "album-0", "track-0.flac", "genre-b") // old track genre missing + + isAlbumGenreMissing("artist-0/", "album-0", "genre-a") // old album genre missing + isAlbumGenreMissing("artist-0/", "album-0", "genre-b") // old album genre missing + + isGenreMissing("genre-a") // old genre missing + isGenreMissing("genre-b") // old genre missing } -func TestMain(m *testing.M) { - db, err := db.NewMock() - if err != nil { - log.Fatalf("error opening database: %v\n", err) +func TestMultiFolders(t *testing.T) { + t.Parallel() + is := is.New(t) + m := mockfs.NewWithDirs(t, []string{"m-1", "m-2", "m-3"}) + defer m.CleanUp() + + m.AddItemsPrefix("m-1") + m.AddItemsPrefix("m-2") + m.AddItemsPrefix("m-3") + m.ScanAndClean() + + var rootDirs []*db.Album + is.NoErr(m.DB().Where("parent_id IS NULL").Find(&rootDirs).Error) + is.Equal(len(rootDirs), 3) + for i, r := range rootDirs { + is.Equal(r.RootDir, filepath.Join(m.TmpDir(), fmt.Sprintf("m-%d", i+1))) + is.Equal(r.ParentID, 0) + is.Equal(r.LeftPath, "") + is.Equal(r.RightPath, ".") } - // benchmarks aren't real code are they? >:) - // here is an absolute path to my music directory - testScanner = New("/home/senan/music", db, "\n") - log.SetOutput(ioutil.Discard) - os.Exit(m.Run()) + + m.AddCover("m-3/artist-0/album-0/cover.jpg") + m.ScanAndClean() + m.LogItems() + + checkCover := func(root string, q string) { + is.Helper() + is.NoErr(m.DB().Where(q, filepath.Join(m.TmpDir(), root)).Find(&db.Album{}).Error) + } + + checkCover("m-1", "root_dir=? AND cover IS NULL") // mf 1 no cover + checkCover("m-2", "root_dir=? AND cover IS NULL") // mf 2 no cover + checkCover("m-3", "root_dir=? AND cover='cover.jpg'") // mf 3 has cover } -// RESULTS fresh -// 20 times / 1.436 -// 20 times / 1.39 +func TestNewAlbumForExistingArtist(t *testing.T) { + t.Parallel() + is := is.New(t) + m := mockfs.New(t) + defer m.CleanUp() + + m.AddItems() + m.ScanAndClean() + + m.LogAlbums() + m.LogArtists() + + var artist db.Artist + is.NoErr(m.DB().Where("name=?", "artist-2").Find(&artist).Error) // find orig artist + is.True(artist.ID > 0) -// RESULTS inc -// 100 times / 1.86 -// 100 times / 1.9 -// 100 times / 1.5 -// 100 times / 1.48 + for tr := 0; tr < 3; tr++ { + m.AddTrack(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr)) + m.SetTags(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr), func(tags *mockfs.Tags) { + tags.RawArtist = "artist-2" + tags.RawAlbumArtist = "artist-2" + tags.RawAlbum = "new-album" + tags.RawTitle = fmt.Sprintf("title-%d", tr) + }) + } + + var updated db.Artist + is.NoErr(m.DB().Where("name=?", "artist-2").Find(&updated).Error) // find updated artist + is.Equal(artist.ID, updated.ID) // find updated artist + + var all []*db.Artist + is.NoErr(m.DB().Find(&all).Error) // still only 3? + is.Equal(len(all), 3) // still only 3? +} diff --git a/server/scanner/stack/stack.go b/server/scanner/stack/stack.go deleted file mode 100644 index 937b78fe..00000000 --- a/server/scanner/stack/stack.go +++ /dev/null @@ -1,61 +0,0 @@ -package stack - -import ( - "fmt" - "strings" - - "go.senan.xyz/gonic/server/db" -) - -type item struct { - value *db.Album - next *item -} - -type Stack struct { - top *item - len uint -} - -func (s *Stack) Push(v *db.Album) { - s.top = &item{ - value: v, - next: s.top, - } - s.len++ -} - -func (s *Stack) Pop() *db.Album { - if s.len == 0 { - return nil - } - v := s.top.value - s.top = s.top.next - s.len-- - return v -} - -func (s *Stack) Peek() *db.Album { - if s.len == 0 { - return nil - } - return s.top.value -} - -func (s *Stack) PeekID() int { - if s.len == 0 { - return 0 - } - return s.top.value.ID -} - -func (s *Stack) String() string { - var str strings.Builder - str.WriteString("[") - for i, f := uint(0), s.top; i < s.len; i++ { - str.WriteString(fmt.Sprintf("%d, ", f.value.ID)) - f = f.next - } - str.WriteString("]") - return str.String() -} diff --git a/server/scanner/stack/stack_test.go b/server/scanner/stack/stack_test.go deleted file mode 100644 index e8389717..00000000 --- a/server/scanner/stack/stack_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package stack - -import ( - "testing" - - "go.senan.xyz/gonic/server/db" -) - -func TestFolderStack(t *testing.T) { - sta := &Stack{} - sta.Push(&db.Album{ID: 3}) - sta.Push(&db.Album{ID: 4}) - sta.Push(&db.Album{ID: 5}) - sta.Push(&db.Album{ID: 6}) - expected := "[6, 5, 4, 3, ]" - actual := sta.String() - if expected != actual { - t.Errorf("first stack: expected string "+ - "%q, got %q", expected, actual) - } - // - sta = &Stack{} - sta.Push(&db.Album{ID: 27}) - sta.Push(&db.Album{ID: 4}) - sta.Peek() - sta.Push(&db.Album{ID: 5}) - sta.Push(&db.Album{ID: 6}) - sta.Push(&db.Album{ID: 7}) - sta.Pop() - expected = "[6, 5, 4, 27, ]" - actual = sta.String() - if expected != actual { - t.Errorf("second stack: expected string "+ - "%q, got %q", expected, actual) - } -} diff --git a/server/scanner/tags/tags.go b/server/scanner/tags/tags.go index 9e0f1d35..58009b06 100644 --- a/server/scanner/tags/tags.go +++ b/server/scanner/tags/tags.go @@ -7,29 +7,19 @@ import ( "github.com/nicksellen/audiotags" ) -func intSep(in, sep string) int { - if in == "" { - return 0 - } - start := strings.SplitN(in, sep, 2)[0] - out, err := strconv.Atoi(start) - if err != nil { - return 0 - } - return out +type TagReader struct{} + +func (*TagReader) Read(abspath string) (Parser, error) { + raw, props, err := audiotags.Read(abspath) + return &Tagger{raw, props}, err } -type Tags struct { +type Tagger struct { raw map[string]string props *audiotags.AudioProperties } -func New(path string) (*Tags, error) { - raw, props, err := audiotags.Read(path) - return &Tags{raw, props}, err -} - -func (t *Tags) firstTag(keys ...string) string { +func (t *Tagger) first(keys ...string) string { for _, key := range keys { if val, ok := t.raw[key]; ok { return val @@ -38,23 +28,25 @@ func (t *Tags) firstTag(keys ...string) string { return "" } -func (t *Tags) Title() string { return t.firstTag("title") } -func (t *Tags) BrainzID() string { return t.firstTag("musicbrainz_trackid") } -func (t *Tags) Artist() string { return t.firstTag("artist") } -func (t *Tags) Album() string { return t.firstTag("album") } -func (t *Tags) AlbumArtist() string { return t.firstTag("albumartist", "album artist") } -func (t *Tags) AlbumBrainzID() string { return t.firstTag("musicbrainz_albumid") } -func (t *Tags) Genre() string { return t.firstTag("genre") } -func (t *Tags) TrackNumber() int { return intSep(t.firstTag("tracknumber"), "/") } // eg. 5/12 -func (t *Tags) DiscNumber() int { return intSep(t.firstTag("discnumber"), "/") } // eg. 1/2 -func (t *Tags) Length() int { return t.props.Length } -func (t *Tags) Bitrate() int { return t.props.Bitrate } -func (t *Tags) Year() int { return intSep(t.firstTag("originaldate", "date", "year"), "-") } +func (t *Tagger) Title() string { return t.first("title") } +func (t *Tagger) BrainzID() string { return t.first("musicbrainz_trackid") } +func (t *Tagger) Artist() string { return t.first("artist") } +func (t *Tagger) Album() string { return t.first("album") } +func (t *Tagger) AlbumArtist() string { return t.first("albumartist", "album artist") } +func (t *Tagger) AlbumBrainzID() string { return t.first("musicbrainz_albumid") } +func (t *Tagger) Genre() string { return t.first("genre") } +func (t *Tagger) TrackNumber() int { return intSep(t.first("tracknumber"), "/") } // eg. 5/12 +func (t *Tagger) DiscNumber() int { return intSep(t.first("discnumber"), "/") } // eg. 1/2 +func (t *Tagger) Length() int { return t.props.Length } +func (t *Tagger) Bitrate() int { return t.props.Bitrate } +func (t *Tagger) Year() int { return intSep(t.first("originaldate", "date", "year"), "-") } -func (t *Tags) SomeAlbum() string { return first("Unknown Album", t.Album()) } -func (t *Tags) SomeArtist() string { return first("Unknown Artist", t.Artist()) } -func (t *Tags) SomeAlbumArtist() string { return first("Unknown Artist", t.AlbumArtist(), t.Artist()) } -func (t *Tags) SomeGenre() string { return first("Unknown Genre", t.Genre()) } +func (t *Tagger) SomeAlbum() string { return first("Unknown Album", t.Album()) } +func (t *Tagger) SomeArtist() string { return first("Unknown Artist", t.Artist()) } +func (t *Tagger) SomeAlbumArtist() string { + return first("Unknown Artist", t.AlbumArtist(), t.Artist()) +} +func (t *Tagger) SomeGenre() string { return first("Unknown Genre", t.Genre()) } func first(or string, strs ...string) string { for _, str := range strs { @@ -64,3 +56,39 @@ func first(or string, strs ...string) string { } return or } + +func intSep(in, sep string) int { + if in == "" { + return 0 + } + start := strings.SplitN(in, sep, 2)[0] + out, err := strconv.Atoi(start) + if err != nil { + return 0 + } + return out +} + +type Reader interface { + Read(abspath string) (Parser, error) +} + +type Parser interface { + Title() string + BrainzID() string + Artist() string + Album() string + AlbumArtist() string + AlbumBrainzID() string + Genre() string + TrackNumber() int + DiscNumber() int + Length() int + Bitrate() int + Year() int + + SomeAlbum() string + SomeArtist() string + SomeAlbumArtist() string + SomeGenre() string +} diff --git a/server/scrobble/lastfm/lastfm.go b/server/scrobble/lastfm/lastfm.go index 851929f9..2b666083 100644 --- a/server/scrobble/lastfm/lastfm.go +++ b/server/scrobble/lastfm/lastfm.go @@ -146,8 +146,15 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su if user.LastFMSession == "" { return nil } - apiKey := s.DB.GetSetting("lastfm_api_key") - secret := s.DB.GetSetting("lastfm_secret") + apiKey, err := s.DB.GetSetting("lastfm_api_key") + if err != nil { + return fmt.Errorf("get api key: %w", err) + } + secret, err := s.DB.GetSetting("lastfm_secret") + if err != nil { + return fmt.Errorf("get secret: %w", err) + } + // fetch user to get lastfm session if user.LastFMSession == "" { return fmt.Errorf("you don't have a last.fm session: %w", ErrLastFM) @@ -169,7 +176,7 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su params.Add("mbid", track.TagBrainzID) params.Add("albumArtist", track.Artist.Name) params.Add("api_sig", getParamSignature(params, secret)) - _, err := makeRequest("POST", params) + _, err = makeRequest("POST", params) return err } diff --git a/server/server.go b/server/server.go index 3b0c03b4..6e2badfa 100644 --- a/server/server.go +++ b/server/server.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gorilla/mux" + "github.com/gorilla/securecookie" "github.com/wader/gormstore" "go.senan.xyz/gonic/server/assets" @@ -18,6 +19,7 @@ import ( "go.senan.xyz/gonic/server/jukebox" "go.senan.xyz/gonic/server/podcasts" "go.senan.xyz/gonic/server/scanner" + "go.senan.xyz/gonic/server/scanner/tags" "go.senan.xyz/gonic/server/scrobble" "go.senan.xyz/gonic/server/scrobble/lastfm" "go.senan.xyz/gonic/server/scrobble/listenbrainz" @@ -48,7 +50,9 @@ func New(opts Options) (*Server, error) { opts.CachePath = filepath.Clean(opts.CachePath) opts.PodcastPath = filepath.Clean(opts.PodcastPath) - scanner := scanner.New(opts.MusicPath, opts.DB, opts.GenreSplit) + tagger := &tags.TagReader{} + + scanner := scanner.New(opts.MusicPaths, false, opts.DB, opts.GenreSplit, tagger) base := &ctrlbase.Controller{ DB: opts.DB, MusicPath: opts.MusicPath, @@ -63,12 +67,21 @@ func New(opts Options) (*Server, error) { } r.Use(base.WithCORS) - sessKey := opts.DB.GetOrCreateKey("session_key") + sessKey, err := opts.DB.GetSetting("session_key") + if err != nil { + return nil, fmt.Errorf("get session key: %w", err) + } + if sessKey == "" { + if err := opts.DB.SetSetting("session_key", string(securecookie.GenerateRandomKey(32))); err != nil { + return nil, fmt.Errorf("set session key: %w", err) + } + } + sessDB := gormstore.New(opts.DB.DB, []byte(sessKey)) sessDB.SessionOpts.HttpOnly = true sessDB.SessionOpts.SameSite = http.SameSiteLaxMode - podcast := &podcasts.Podcasts{DB: opts.DB, PodcastBasePath: opts.PodcastPath} + podcast := podcasts.New(opts.DB, opts.PodcastPath, tagger) ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast) if err != nil { @@ -78,11 +91,10 @@ func New(opts Options) (*Server, error) { Controller: base, CachePath: opts.CachePath, CoverCachePath: opts.CoverCachePath, - Scrobblers: []scrobble.Scrobbler{ - &lastfm.Scrobbler{DB: opts.DB}, - &listenbrainz.Scrobbler{}, - }, - Podcasts: podcast, + PodcastsPath: opts.PodcastPath, + Jukebox: &jukebox.Jukebox{}, + Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}}, + Podcasts: podcast, } setupMisc(r, base) @@ -272,7 +284,7 @@ func (s *Server) StartScanTicker(dur time.Duration) (FuncExecute, FuncInterrupt) return nil case <-ticker.C: go func() { - if err := s.scanner.Start(scanner.ScanOptions{}); err != nil { + if err := s.scanner.ScanAndClean(scanner.ScanOptions{}); err != nil { log.Printf("error scanning: %v", err) } }()