commit 1645a9b8334c45221a9559c7cab86244c186d8ce Author: velvettear Date: Tue Mar 14 09:53:33 2023 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8e41cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__debug_bin +*.sqlite* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a2e28f1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "badger", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go", + "args": [ + ] + }, + { + "name": "badger-min", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/badger-min/badger-min.go", + "args": [ + ] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c65ede2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "sqlite.logLevel": "DEBUG" +} \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d342365 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +# MIT License +**Copyright (c) 2022 Daniel Sommer \** + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.** \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..76fb7f0 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# badger + +a management tool for your audio library written in go + +## requirements + +**currently only 64bit x86 linux systems are supported!** + +- [golang](https://go.dev/) >= 1.19 +- [sqlite3](https://www.sqlite.org/index.html) +- [ffprobe](https://ffmpeg.org/ffprobe.html) (*optional*) +- [fpcalc](https://acoustid.org/chromaprint) (*optional*) + +## how to build + +1. `git clone https://git.velvettear.de/velvettear/badger.git` +2. `cd badger` +3. `go build` +4. `go clean --cache` + +**note:** +the resulting binary file is relatively large. this is because the binaries for `ffprobe` and `fpcalc` are bundled within the binary itself and get extracted on the first run. + +if `ffprobe` and `fpcalc` is already installed on your system and you wish to use the system provided binaries instead it is recommended to build and use the minified version. +to do so replace the line 3 with: `go build cmd/badger-min/badger-min.go`. + +## arguments + +there are just a two arguments to pass to `badger`: + +| argument | description | +| ---------------- | ----------------------------------- | +| `-h`, `--help` | print the help | +| `-c`, `--config` | specify the path to the config file | + +## configuration + +configuration is done within a '[.yaml](https://yaml.org/)' file. +to get a first impression just take a look at the provided [config](https://git.velvettear.de/velvettear/badger/src/branch/master/config.yml). + +### directories + +| name | type | default | description | +| ---------- | -------| ---------------------- | ---------------------------------------------------------- | +| home | string | $TMP/badger | directory where `badger` stores its data | +| library | string | $TMP/badger/library | directory where your audio files reside | +| import | string | $TMP/badger/import | directory from which to import audio files to your library | +| duplicates | string | $TMP/badger/duplicates | directory into which duplicates are moved or linked | + +### database + +| name | type | default | description | +| ----------- | -------| ------------------------- | ---------------------------------------------------------------------------------------------- | +| file | string | $TMP/badger/badger.sqlite | path to the sqlite database file | +| inMemory | bool | true | use an in memory sqlite database | +| chunkSize | int | 1000 | chunk size of database entries to copy into the in memory sqlite database | +| busyTimeout | int | 10000 | [busy timeout](https://www.sqlite.org/pragma.html#pragma_busy_timeout) for the sqlite database | +| journalMode | string | off | [journal mode](https://www.sqlite.org/pragma.html#pragma_journal_mode) for the sqlite database | + +### library + +| name | type | default | description | +| ------------------------- | -------------- | ------------------- | ------------------------------------------------- | +| formats | array [string] | flac, mp3 | array of file formats to parse | +| changedetection.modified | bool | true | check for file changes by the modified time stamp | +| changedetection.size | bool | true | check for file changes by the file size | +| changedetection.checksum | bool | false | check for file changes by checksum
**(this will dramatically increase update duration)** | +| duplicates.action | string | log | action to perform on found duplicates:
`ignore`, `log`, `link`, `move`, `delete` | +| duplicates.formatMismatch | bool | true | check only files with non matching file formats | +| duplicates.useFingerprint | bool | true | check for duplicates by comparing audio fingerprints
if set to `false` a hash sum of the metadata will be used for comparison
**(audio fingerprints are more precise, but a lot slower)** | +| duplicates.fingerprintThreshold | float64 | 0.95 | threshold score for the fingerprint comparison | + +### api +| name | type | default | description | +| ------ | ------ | --------- | -------------------------------- | +| listen | string | 0.0.0.0 | listen address of the api server | +| port | int | 3333 | port of the api server | + +### other + +| name | type | default | description | +| ----------- | ---- | ---------------------- | ----------------------------------------------------------------------------------------------- | +| concurrency | int | number of cpu cores | amount of concurrent ffprobe/fpcalc processes or goroutines for fingerprint comparison to spawn | +| debug | bool | true | enable or disable debug mode | + +## credits + +thanks to all these amazing people and their projects! +without them this would not be possible. + +- [Chromaprint](https://acoustid.org/chromaprint) +- [FFmpeg](https://ffmpeg.org/) +- [GORM](https://gorm.io/) +- [viper](https://github.com/spf13/viper/tree/b89e554a96abde447ad13a26dcc59fd00375e555) +- [xxhash](https://github.com/cespare/xxhash) \ No newline at end of file diff --git a/cmd/badger-min/badger-min.go b/cmd/badger-min/badger-min.go new file mode 100644 index 0000000..c1398ce --- /dev/null +++ b/cmd/badger-min/badger-min.go @@ -0,0 +1,7 @@ +package main + +import badger "velvettear/badger/internal" + +func main() { + badger.Run() +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..7902ae5 --- /dev/null +++ b/config.yaml @@ -0,0 +1,34 @@ +directories: + home: /tmp/badger # badger home directory + library: /run/media/velvettear/ext1tb_elements/badger # music library + import: /tmp/badger/import # import directory + duplicates: /tmp/badger/duplicates # duplicates directory + +database: + file: /tmp/badger/badger.sqlite # path to the sqlite database file + inMemory: true # use an in memory sqlite database + chunkSize: 1000 # chunk size of database entries to copy into the in memory sqlite database + busyTimeout: 10000 # busy timeout for the sqlite database (https://www.sqlite.org/pragma.html#pragma_busy_timeout) + journalMode: off # journal mode for the sqlite database (https://www.sqlite.org/pragma.html#pragma_journal_mode) + +library: + formats: # array of file formats to parse + - flac + - mp3 + changedetection: + modified: true # check for file changes by the modified time stamp + size: true # check for file changes by the file size + checksum: false # check for file changes by checksum (https://github.com/cespare/xxhash) + duplicates: + action: log # action to perform on found duplicates + formatMismatch: true # check only files with non matching file formats + useFingerprint: true # check for duplicates by comparing audio fingerprints + fingerprintThreshold: 0.95 # threshold score for the fingerprint comparison + +api: + listen: 0.0.0.0 # listen address of the api server + port: 3333 # port of the api server + +concurrency: 2 # amount of concurrent ffprobe/fpcalc processes or goroutines for fingerprint comparison to spawn + +debug: true # enable or disable debug mode \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ddcb6a2 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module velvettear/badger + +go 1.20 + +require ( + github.com/cespare/xxhash v1.1.0 + github.com/spf13/viper v1.13.0 + gorm.io/driver/sqlite v1.4.3 + gorm.io/gorm v1.24.0 +) + +require ( + github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/magiconair/properties v1.8.6 // indirect + github.com/mattn/go-sqlite3 v1.14.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.0.5 // indirect + github.com/spf13/afero v1.8.2 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.4.1 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + golang.org/x/text v0.3.7 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d576c99 --- /dev/null +++ b/go.sum @@ -0,0 +1,496 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg= +github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo= +github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.13.0 h1:BWSJ/M+f+3nmdz9bxB+bWX28kkALN2ok11D0rSo8EJU= +github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= +github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/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-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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-20190108225652-1e06a53dbb7e/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= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= +gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74= +gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..7a69278 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,67 @@ +package api + +import ( + "net/http" + "strconv" + "velvettear/badger/internal/config" + "velvettear/badger/internal/library" + "velvettear/badger/internal/log" +) + +// exported function(s) +func StartServer() { + serverAddress := config.ApiListen() + ":" + strconv.Itoa(config.ApiPort()) + registerEndpoints() + log.Info("starting the api server", "address: '"+serverAddress+"'") + error := http.ListenAndServe(serverAddress, nil) + if error != nil { + log.Fatal("encountered an error starting the api server", error.Error()) + } +} + +// unexported function(s) +func registerEndpoints() { + endpoint{ + address: "/library/update", + async: false, + function: library.Update, + }.register() + endpoint{ + address: "/library/import", + async: false, + function: library.Import, + }.register() + endpoint{ + address: "/library/duplicates", + async: false, + function: library.FindDuplicates, + }.register() +} + +func (endpoint endpoint) register() { + log.Debug("registering api endpoint '" + endpoint.address + "'...") + http.HandleFunc(endpoint.address, func(response http.ResponseWriter, request *http.Request) { + log.Debug("called api endpoint '" + endpoint.address + "'...") + if !runBlocking(endpoint.address) { + response.Write([]byte("function '" + getRunning().functionName + "' is already running")) + response.WriteHeader(503) + } + state := getRunning() + msg := "function '" + state.functionName + "'" + if endpoint.async { + go endpoint.function.(func())() + msg += " started asynchronously" + } else { + endpoint.function.(func())() + msg += " finished after " + strconv.Itoa(int(finish().Milliseconds())) + "ms" + } + response.Write([]byte(msg)) + }) +} + +// struct(s) +type endpoint struct { + address string + async bool + function interface{} +} diff --git a/internal/api/state.go b/internal/api/state.go new file mode 100644 index 0000000..47e1046 --- /dev/null +++ b/internal/api/state.go @@ -0,0 +1,39 @@ +package api + +import "time" + +var state State + +// unexported function(s) +func runBlocking(function string) bool { + return run(function, true) +} + +func run(function string, blocking bool) bool { + if state.blocking { + return false + } + state.functionName = function + state.blocking = blocking + timestamp := time.Now() + state.startTime = timestamp + state.stopTime = timestamp + return true +} + +func getRunning() State { + return state +} + +func finish() time.Duration { + state.stopTime = time.Now() + return state.stopTime.Sub(state.startTime) +} + +// struct(s) +type State struct { + functionName string + blocking bool + startTime time.Time + stopTime time.Time +} diff --git a/internal/badger.go b/internal/badger.go new file mode 100644 index 0000000..b9979fd --- /dev/null +++ b/internal/badger.go @@ -0,0 +1,48 @@ +package badger + +import ( + "embed" + "strings" + "velvettear/badger/internal/api" + "velvettear/badger/internal/config" + "velvettear/badger/internal/database" + "velvettear/badger/internal/executables" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" +) + +var embeddedFiles *embed.FS + +// exported function(s) +func Run() { + config.Initialize() + if config.Help() { + tools.PrintHelp() + } + setup() + api.StartServer() + // library.Update() + // library.Import() + // library.FindDuplicates() +} + +func SetEmbeddedFiles(files *embed.FS) { + embeddedFiles = files +} + +// unexported function(s) +func setup() { + home := config.HomeDirectory() + error := tools.MkDirs(home) + if error != nil { + log.Fatal("could not create home directory '"+home+"'", error.Error()) + } else { + log.Info("created home directory '" + home + "'") + } + executables.Initialize() + executables.ExportExecutables(embeddedFiles) + if !executables.AreMandatoryExecutablesAvailable() { + log.Fatal("error locating all mandatory executables, make sure they are correctly installed on your system", strings.Join(executables.GetMissingExecutables(), ", ")) + } + database.Initialize() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4992688 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "path" + "runtime" + "strings" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" + + "github.com/spf13/viper" +) + +// exported function(s) +func Initialize() { + timestamp := tools.LogTimestamp() + configSpecified := false + for index, arg := range os.Args { + if arg == "-h" || arg == "--"+help { + tools.PrintHelp() + } + if arg == "-c" || arg == "--"+config { + viper.SetConfigFile(os.Args[index+1]) + configSpecified = true + } + } + setDefaults() + if !configSpecified { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath("$HOME/.config/badger/") + viper.AddConfigPath("$HOME/.config/") + workingDirectory, error := os.Getwd() + for error == nil && path.Base(workingDirectory) != "badger" { + workingDirectory = path.Dir(workingDirectory) + if workingDirectory == "/" { + workingDirectory = "." + break + } + } + viper.AddConfigPath(workingDirectory) + } + error := viper.ReadInConfig() + if configSpecified && error != nil { + log.FatalTimed("encountered an error parsing specified config file '"+viper.ConfigFileUsed()+"'", timestamp, error.Error()) + } + tmp := viper.GetStringSlice(libraryFormats) + for index, format := range tmp { + if strings.HasPrefix(format, ".") { + continue + } + tmp[index] = "." + format + } + viper.Set(libraryFormats, tmp) + if viper.GetInt(concurrency) <= 0 { + viper.Set(concurrency, runtime.NumCPU()) + } + viper.Set(databaseJournalMode, strings.ToLower(viper.GetString(databaseJournalMode))) + prepareFilesystem() + if Debug() { + log.SetLogLevel(0) + } + log.DebugTimed("parsed config file '"+viper.ConfigFileUsed()+"'", timestamp) +} + +// unexported function(s) +func prepareFilesystem() { + tmp := []string{ + viper.GetString(homeDirectory), + viper.GetString(libraryDirectory), + viper.GetString(importDirectory), + viper.GetString(duplicatesDirectoy), + } + for _, directory := range tmp { + error := tools.MkDirs(directory) + if error != nil { + log.Fatal("encountered an error creating directory '"+directory+"'", error.Error()) + } + } + // tmp = []string{} + // for _, file := range tmp { + // error := tools.CreateFile(file) + // if error != nil { + // log.Fatal("encountered an error creating file '"+file+"'", error.Error()) + // } + // } +} + +func setDefaults() { + home := path.Join(os.TempDir(), "badger") + // set directory defaults + viper.SetDefault(homeDirectory, home) + viper.SetDefault(libraryDirectory, path.Join(home, "library")) + viper.SetDefault(importDirectory, path.Join(home, "import")) + viper.SetDefault(duplicatesDirectoy, path.Join(home, "duplicates")) + // set database defaults + viper.SetDefault(databaseFile, path.Join(home, "badger.sqlite")) + viper.SetDefault(databaseInMemory, true) + viper.SetDefault(databaseChunkSize, 1000) + viper.SetDefault(databaseBusyTimeout, 10000) + viper.SetDefault(databaseJournalMode, "off") + // set library defaults + viper.SetDefault(libraryFormats, []string{"flac", "mp3"}) + viper.SetDefault(concurrency, runtime.NumCPU()) + viper.SetDefault(libraryChangedetectionModified, true) + viper.SetDefault(libraryChangedetectionSize, true) + viper.SetDefault(libraryChangedetectionChecksum, false) + viper.SetDefault(libraryDuplicatesAction, "log") + viper.SetDefault(libraryDuplicatesFormatMismatch, true) + viper.SetDefault(libraryDuplicatesUseFingerprint, true) + viper.SetDefault(libraryDuplicatesFingerprintThreshold, 0.95) + // set api defaults + viper.SetDefault(apiListen, "localhost") + viper.SetDefault(apiPort, 3333) + // set other defaults + viper.SetDefault(debug, false) + viper.SetDefault(help, false) +} diff --git a/internal/config/constants.go b/internal/config/constants.go new file mode 100644 index 0000000..459a48a --- /dev/null +++ b/internal/config/constants.go @@ -0,0 +1,34 @@ +package config + +// directory settings +const homeDirectory = "directories.home" +const libraryDirectory = "directories.library" +const importDirectory = "directories.import" +const duplicatesDirectoy = "directories.duplicates" + +// database settings +const databaseFile = "database.file" +const databaseInMemory = "database.inMemory" +const databaseBusyTimeout = "database.busyTimeout" +const databaseJournalMode = "database.journalMode" +const databaseChunkSize = "database.chunkSize" + +// library settings +const libraryFormats = "library.formats" +const libraryChangedetectionModified = "library.changedetection.modified" +const libraryChangedetectionSize = "library.changedetection.size" +const libraryChangedetectionChecksum = "library.changedetection.checksum" +const libraryDuplicatesAction = "library.duplicates.action" +const libraryDuplicatesFormatMismatch = "library.duplicates.formatMismatch" +const libraryDuplicatesUseFingerprint = "library.duplicates.useFingerprint" +const libraryDuplicatesFingerprintThreshold = "library.duplicates.fingerprintThreshold" + +// api settings +const apiListen = "api.listen" +const apiPort = "api.port" + +// other settings +const concurrency = "concurrency" +const config = "config" +const help = "help" +const debug = "debug" diff --git a/internal/config/getter.go b/internal/config/getter.go new file mode 100644 index 0000000..cb86895 --- /dev/null +++ b/internal/config/getter.go @@ -0,0 +1,88 @@ +package config + +import "github.com/spf13/viper" + +// exported function(s) +func HomeDirectory() string { + return viper.GetString(homeDirectory) +} + +func LibraryDirectory() string { + return viper.GetString(libraryDirectory) +} + +func ImportDirectory() string { + return viper.GetString(importDirectory) +} + +func DatabaseFile() string { + return viper.GetString(databaseFile) +} + +func DatabaseInMemory() bool { + return viper.GetBool(databaseInMemory) +} + +func DatabaseBusyTimeout() int { + return viper.GetInt(databaseBusyTimeout) +} + +func DatabaseJournalMode() string { + return viper.GetString(databaseJournalMode) +} + +func DatabaseChunkSize() int { + return viper.GetInt(databaseChunkSize) +} + +func Formats() []string { + return viper.GetStringSlice(libraryFormats) +} + +func Concurrency() int { + return viper.GetInt(concurrency) +} + +func ChangeDetectionModified() bool { + return viper.GetBool(libraryChangedetectionModified) +} + +func ChangeDetectionSize() bool { + return viper.GetBool(libraryChangedetectionSize) +} + +func ChangeDetectionChecksum() bool { + return viper.GetBool(libraryChangedetectionChecksum) +} + +func DuplicatesAction() string { + return viper.GetString(libraryDuplicatesAction) +} + +func DuplicatesFormatMismatch() bool { + return viper.GetBool(libraryDuplicatesFormatMismatch) +} + +func DuplicatesUseFingerprint() bool { + return viper.GetBool(libraryDuplicatesUseFingerprint) +} + +func DuplicatesFingerprintThreshold() float64 { + return viper.GetFloat64(libraryDuplicatesFingerprintThreshold) +} + +func ApiListen() string { + return viper.GetString(apiListen) +} + +func ApiPort() int { + return viper.GetInt(apiPort) +} + +func Help() bool { + return viper.GetBool(help) +} + +func Debug() bool { + return viper.GetBool(debug) +} diff --git a/internal/database/connection.go b/internal/database/connection.go new file mode 100644 index 0000000..a63601b --- /dev/null +++ b/internal/database/connection.go @@ -0,0 +1,251 @@ +package database + +import ( + "io" + "os" + "path" + "strconv" + "time" + "velvettear/badger/internal/config" + "velvettear/badger/internal/database/models" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" + + defaultLog "log" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const inMemoryUrl = "file:memdb?mode=memory&cache=shared" + +var memoryConnection *gorm.DB +var fileConnection *gorm.DB + +// exported function(s) +func Initialize() { + var connection *gorm.DB + connection, error := connect(config.DatabaseFile()) + if error != nil { + log.Fatal("encountered an error connecting to file database '"+config.DatabaseFile()+"'", error.Error()) + } + fileConnection = connection + connection, error = connect(inMemoryUrl) + if error != nil { + log.Fatal("encountered an error connecting to the in memory database", error.Error()) + } + memoryConnection = connection + setSQLiteBusyTimeout() + setSQLiteJournalMode() + copyFileToInMemory() +} + +func Connection() *gorm.DB { + if config.DatabaseInMemory() { + return memoryConnection + } + return fileConnection +} + +func Memory() *gorm.DB { + return memoryConnection +} + +func File() *gorm.DB { + return fileConnection +} + +func Persist() { + if !config.DatabaseInMemory() { + return + } + timestamp := tools.LogTimestamp() + database := config.DatabaseFile() + if tools.Exists(database) { + backup := path.Base(database) + "-backup" + path.Ext(database) + if tools.Exists(backup) { + error := tools.Delete(backup) + if error != nil { + log.Error("encountered an error deleting the backup database '"+backup+"'", error.Error()) + } + } + error := tools.MoveFile(database, database+".backup") + if error != nil { + log.Error("encountered an error renaming/moving the database '"+database+"' to '"+backup+"'", error.Error()) + } + } + memoryConnection.Exec("VACUUM INTO '" + database + "'") + log.DebugTimed("persisted in memory database to '"+database+"'", timestamp) +} + +// unexported function(s) +func createLogger() logger.Interface { + if !config.Debug() { + return logger.New(defaultLog.New(io.Discard, "", defaultLog.LstdFlags), logger.Config{}) + } + return logger.New( + defaultLog.New(os.Stdout, "\r\n", defaultLog.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Warn, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ) +} + +func connect(path string) (*gorm.DB, error) { + var connection *gorm.DB + if path == inMemoryUrl && !config.DatabaseInMemory() { + return connection, nil + } + if path != inMemoryUrl && !tools.Exists(path) { + tools.CreateFile(path) + } + connection, error := gorm.Open(sqlite.Open(path), &gorm.Config{ + Logger: createLogger(), + }) + if error != nil { + return connection, error + } + migrateSchema(connection) + return connection, nil +} + +func migrateSchema(connection *gorm.DB) { + connection.AutoMigrate(&models.Track{}) + connection.AutoMigrate(&models.Artist{}) + connection.AutoMigrate(&models.Album{}) +} + +func copyFileToInMemory() { + timestamp := tools.LogTimestamp() + database := config.DatabaseFile() + chunkSize := config.DatabaseChunkSize() + if chunkSize == 0 { + log.Info("copying content of database '" + config.DatabaseFile() + "' to memory...") + // copy artists + tmpTimestamp := tools.LogTimestamp() + var artists []models.Artist + result := fileConnection.Find(&artists) + if result.RowsAffected > 0 { + result := memoryConnection.Create(artists) + log.DebugTimed("copied "+strconv.FormatInt(result.RowsAffected, 10)+" artists from '"+database+"' to memory", tmpTimestamp) + } + // copy albums + tmpTimestamp = tools.LogTimestamp() + var albums []models.Album + result = fileConnection.Find(&albums) + if result.RowsAffected > 0 { + result := memoryConnection.Create(albums) + log.DebugTimed("copied "+strconv.FormatInt(result.RowsAffected, 10)+" albums from '"+database+"' to memory", tmpTimestamp) + } + // copy tracks + tmpTimestamp = tools.LogTimestamp() + var tracks []models.Track + result = fileConnection.Find(&tracks) + if result.RowsAffected > 0 { + result := memoryConnection.Create(tracks) + log.DebugTimed("copied "+strconv.FormatInt(result.RowsAffected, 10)+" artists from '"+database+"' to memory", tmpTimestamp) + } + log.InfoTimed("copied content of database '"+config.DatabaseFile()+"' to memory", timestamp) + return + } + + log.Info("copying content of database '" + config.DatabaseFile() + "' to memory in chunks of " + strconv.Itoa(chunkSize) + "...") + // copy artists + tmpTimestamp := tools.LogTimestamp() + var artists []models.Artist + result := fileConnection.FindInBatches(&artists, chunkSize, func(batch *gorm.DB, index int) error { + if batch.RowsAffected == 0 { + return nil + } + memoryConnection.Create(artists) + return nil + }) + log.DebugTimed("copied "+strconv.FormatInt(result.RowsAffected, 10)+" artists from '"+database+"' to memory", tmpTimestamp) + // copy albums + tmpTimestamp = tools.LogTimestamp() + var albums []models.Album + result = fileConnection.FindInBatches(&albums, chunkSize, func(batch *gorm.DB, index int) error { + if batch.RowsAffected == 0 { + return nil + } + memoryConnection.Create(albums) + return nil + }) + log.DebugTimed("copied "+strconv.FormatInt(result.RowsAffected, 10)+" albums from '"+database+"' to memory", tmpTimestamp) + // copy tracks + tmpTimestamp = tools.LogTimestamp() + var tracks []models.Track + result = fileConnection.FindInBatches(&tracks, chunkSize, func(batch *gorm.DB, index int) error { + if batch.RowsAffected == 0 { + return nil + } + memoryConnection.Create(tracks) + return nil + }) + log.DebugTimed("copied "+strconv.FormatInt(result.RowsAffected, 10)+" tracks from '"+database+"' to memory", tmpTimestamp) + log.InfoTimed("copied content of database '"+config.DatabaseFile()+"' to memory", timestamp) +} + +func setSQLiteBusyTimeout() { + timestamp := tools.LogTimestamp() + busyTimeout := config.DatabaseBusyTimeout() + var defaultTimeout int + Connection().Raw("PRAGMA busy_timeout").Scan(&defaultTimeout) + if defaultTimeout == busyTimeout { + log.InfoTimed("did not set 'busy_timeout', current value is already "+strconv.Itoa(busyTimeout), timestamp) + return + } + Connection().Exec("PRAGMA busy_timeout = " + strconv.Itoa(busyTimeout)) + var newTimeout int + Connection().Raw("PRAGMA busy_timeout").Scan(&newTimeout) + if newTimeout != busyTimeout { + log.WarningTimed("could not set 'busy_timeout' from "+strconv.Itoa(defaultTimeout)+" to "+strconv.Itoa(busyTimeout), timestamp) + return + } + log.InfoTimed("set 'busy_timeout' from "+strconv.Itoa(defaultTimeout)+"ms to "+strconv.Itoa(newTimeout)+"ms", timestamp) +} + +func setSQLiteJournalMode() { + timestamp := tools.LogTimestamp() + journalMode := config.DatabaseJournalMode() + if tools.IsEmpty(journalMode) { + log.Warning("aborting to set 'journal.mode' to an empty value") + return + } + var availableJournalModes []string + if config.DatabaseInMemory() { + availableJournalModes = append( + availableJournalModes, + SQLITE_JOURNALMODE_MEMORY, + SQLITE_JOURNALMODE_OFF, + ) + } else { + availableJournalModes = append( + availableJournalModes, + SQLITE_JOURNALMODE_DELETE, + SQLITE_JOURNALMODE_TRUNCATE, + SQLITE_JOURNALMODE_PERSIST, + SQLITE_JOURNALMODE_MEMORY, + SQLITE_JOURNALMODE_WAL, + SQLITE_JOURNALMODE_OFF, + ) + } + var defaultJournalMode string + Connection().Raw("PRAGMA journal_mode").Scan(&defaultJournalMode) + if !tools.ContainsString(availableJournalModes, journalMode) { + log.WarningTimed("could not set 'journal_mode' to '"+journalMode+"', mode is unavailable", timestamp) + return + } + var newJournalMode string + Connection().Exec("PRAGMA journal_mode = '" + journalMode + "'") + Connection().Raw("PRAGMA journal_mode").Scan(&newJournalMode) + if newJournalMode != journalMode { + log.WarningTimed("could not set 'journal_mode' from "+defaultJournalMode+" to "+journalMode, timestamp) + return + } + log.InfoTimed("set 'journal_mode' from '"+defaultJournalMode+"' to '"+newJournalMode+"'", timestamp) +} diff --git a/internal/database/constants.go b/internal/database/constants.go new file mode 100644 index 0000000..8950375 --- /dev/null +++ b/internal/database/constants.go @@ -0,0 +1,8 @@ +package database + +const SQLITE_JOURNALMODE_DELETE = "delete" +const SQLITE_JOURNALMODE_TRUNCATE = "truncate" +const SQLITE_JOURNALMODE_PERSIST = "persist" +const SQLITE_JOURNALMODE_MEMORY = "memory" +const SQLITE_JOURNALMODE_WAL = "wal" +const SQLITE_JOURNALMODE_OFF = "off" diff --git a/internal/database/models/album.go b/internal/database/models/album.go new file mode 100644 index 0000000..835d6a7 --- /dev/null +++ b/internal/database/models/album.go @@ -0,0 +1,11 @@ +package models + +import "gorm.io/gorm" + +// struct(s) +type Album struct { + gorm.Model + ID int + Name string + Tracks []Track +} diff --git a/internal/database/models/artist.go b/internal/database/models/artist.go new file mode 100644 index 0000000..1a923c1 --- /dev/null +++ b/internal/database/models/artist.go @@ -0,0 +1,11 @@ +package models + +import "gorm.io/gorm" + +// struct(s) +type Artist struct { + gorm.Model + ID int + Name string + Tracks []Track +} diff --git a/internal/database/models/track.go b/internal/database/models/track.go new file mode 100644 index 0000000..c03ffe5 --- /dev/null +++ b/internal/database/models/track.go @@ -0,0 +1,60 @@ +package models + +import ( + "os" + "strconv" + "velvettear/badger/internal/config" + "velvettear/badger/internal/tools" + + "gorm.io/gorm" +) + +// exported function(s) +func (track *Track) HasChanged() (bool, error) { + if config.ChangeDetectionChecksum() { + checksum, error := tools.XxHash(track.Path) + if error != nil { + return true, error + } + if track.Checksum != strconv.FormatUint(checksum, 10) { + return true, nil + } + } + if !config.ChangeDetectionModified() && config.ChangeDetectionSize() { + return false, nil + } + stats, error := os.Stat(track.Path) + if error != nil { + return true, error + } + if config.ChangeDetectionModified() { + if track.Modified != stats.ModTime().UnixMilli() { + return true, nil + } + } + if config.ChangeDetectionSize() { + if track.Size != stats.Size() { + return true, nil + } + } + return false, nil +} + +// struct(s) +type Track struct { + gorm.Model + ID int + Path string + Title string + Date string + TrackNo string + DiscNo string + Duration string + Bitrate int + Fingerprint string + Checksum string + Modified int64 + Size int64 + ArtistID int + AlbumID int +} diff --git a/internal/executables/all.go b/internal/executables/all.go new file mode 100644 index 0000000..c7a6270 --- /dev/null +++ b/internal/executables/all.go @@ -0,0 +1,123 @@ +package executables + +import ( + "embed" + "errors" + "io" + "io/fs" + "os/exec" + "path" + "strings" + "velvettear/badger/internal/log" +) + +const FFPROBE = "ffprobe" +const FPCALC = "fpcalc" + +var MandatoryExecutables []string +var Executables []Executable + +// exported function(s) +func Initialize(mandatoryExecutables ...string) { + if len(mandatoryExecutables) == 0 { + MandatoryExecutables = []string{FFPROBE, FPCALC} + } else { + MandatoryExecutables = mandatoryExecutables + } + Executables = locateSystemExecutables() +} + +func GetExecutable(executableName string) (Executable, error) { + for _, executable := range Executables { + if strings.ToLower(executable.Name) == strings.ToLower(executableName) { + return executable, nil + } + } + return Executable{}, errors.New("executable '" + executableName + "' is unavailable") +} + +func GetMissingExecutables() []string { + missingExecutables := []string{} + for _, mandatoryExecutableName := range MandatoryExecutables { + mandatoryExecutableName = strings.ToLower(mandatoryExecutableName) + for _, availableExecutable := range Executables { + availableExecutableName := strings.ToLower(availableExecutable.Name) + if strings.Contains(availableExecutableName, mandatoryExecutableName) { + continue + } + } + missingExecutables = append(missingExecutables, mandatoryExecutableName) + } + return missingExecutables +} + +func ExportExecutables(embeddedFS *embed.FS) { + error := fs.WalkDir(embeddedFS, ".", func(file string, entry fs.DirEntry, error error) error { + if entry.IsDir() || file == "." { + return nil + } + executable, error := exportThirdPartyExecutable(embeddedFS, path.Base(file)) + if error != nil { + return error + } + Executables = append(Executables, executable) + return nil + }) + if error != nil { + log.Fatal("encountered one or more errors exporting third party executables", error.Error()) + } +} + +func AreMandatoryExecutablesAvailable() bool { + for { + mandatoryExecutable := MandatoryExecutables[0] + for _, availableExecutable := range Executables { + if availableExecutable.Name != mandatoryExecutable { + continue + } + MandatoryExecutables = MandatoryExecutables[1:] + break + } + if len(MandatoryExecutables) == 0 { + break + } + } + return len(MandatoryExecutables) == 0 +} + +func (executable Executable) Spawn(arguments ...string) (string, error) { + return spawnProcess(executable.Path, arguments...) +} + +func spawnProcess(command string, arguments ...string) (string, error) { + cmd := exec.Command(command, arguments...) + stdout, stdoutError := cmd.StdoutPipe() + stderr, stderrError := cmd.StderrPipe() + cmd.Start() + if stdoutError != nil { + return "", stdoutError + } + if stderrError != nil { + return "", stderrError + } + resultBytes, stdoutError := io.ReadAll(stdout) + if stdoutError != nil { + return "", stdoutError + } + errorBytes, stderrError := io.ReadAll(stderr) + if stderrError != nil { + return "", stderrError + } + cmd.Wait() + error := strings.Trim(string(errorBytes), "\n") + if len(error) > 0 { + return "", errors.New(error) + } + return strings.Trim(string(resultBytes), "\n"), nil +} + +// struct(s) +type Executable struct { + Name string + Path string +} diff --git a/internal/executables/system.go b/internal/executables/system.go new file mode 100644 index 0000000..95322f2 --- /dev/null +++ b/internal/executables/system.go @@ -0,0 +1,38 @@ +package executables + +import ( + "errors" + "os" + "velvettear/badger/internal/log" +) + +// unexported function(s) +func locateSystemExecutables() []Executable { + executables := []Executable{} + for _, executableName := range MandatoryExecutables { + executable, error := locateSystemExecutable(executableName) + if error != nil { + log.Debug("encountered an error locating system executable '"+executableName+"'", error.Error()) + continue + } + executables = append(executables, executable) + } + return executables +} + +func locateSystemExecutable(executableName string) (Executable, error) { + executable := Executable{Name: executableName} + if len(executable.Name) == 0 { + return executable, errors.New("no executable name given") + } + result, _ := spawnProcess("which", executable.Name) + stats, error := os.Stat(result) + if error != nil { + return executable, error + } + executable.Path = result + if stats.Mode()&0100 != 0 { + return executable, errors.New("file '" + executable.Name + "' (" + executable.Path + ") is not executable") + } + return executable, nil +} diff --git a/internal/executables/third_party.go b/internal/executables/third_party.go new file mode 100644 index 0000000..c5bb6e0 --- /dev/null +++ b/internal/executables/third_party.go @@ -0,0 +1,42 @@ +package executables + +import ( + "embed" + "os" + "path" + "velvettear/badger/internal/config" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" +) + +func exportThirdPartyExecutable(embeddedFS *embed.FS, name string) (Executable, error) { + timestamp := tools.LogTimestamp() + executable := Executable{ + Name: name, + Path: path.Join(config.HomeDirectory(), "third_party", name), + } + var msg string + if tools.Exists(executable.Path) { + msg = "binary '" + executable.Path + "' already exists, skipping export" + } else { + file, error := embeddedFS.ReadFile(path.Join("third_party", name)) + if error != nil { + return Executable{}, error + } + error = tools.MkDirs(path.Dir(executable.Path)) + if error != nil { + return Executable{}, error + } + error = os.WriteFile(executable.Path, file, os.ModeAppend) + if error != nil { + return Executable{}, error + } + msg = "exported binary '" + executable.Path + "'" + } + error := os.Chmod(executable.Path, 0700) + if error != nil { + return Executable{}, error + } + log.DebugTimed(msg, timestamp) + return executable, nil +} diff --git a/internal/library/duplicates.go b/internal/library/duplicates.go new file mode 100644 index 0000000..183166f --- /dev/null +++ b/internal/library/duplicates.go @@ -0,0 +1,145 @@ +package library + +import ( + "database/sql" + "path" + "strconv" + "velvettear/badger/internal/config" + "velvettear/badger/internal/database" + "velvettear/badger/internal/database/models" + "velvettear/badger/internal/log" + "velvettear/badger/internal/metadata" + "velvettear/badger/internal/tools" +) + +// exported function(s) +func FindDuplicates() { + timestamp := tools.LogTimestamp() + var duplicates []Duplicate + rows, error := database.Connection().Model(&models.Track{}).Select("id", "path", "fingerprint", "bitrate").Rows() + if error != nil { + log.Error("encountered an error selecting all tracks as rows", error.Error()) + return + } + defer rows.Close() + formatMismatch := config.DuplicatesFormatMismatch() + var comparisonObjects []comparisonObject + for rows.Next() { + comparisonObject, error := toComparisonObject(rows) + if error != nil { + continue + } + comparisonObjects = append(comparisonObjects, comparisonObject) + } + waitChannel := make(chan struct{}, config.Concurrency()) + var objectFormat string + var duplicateIndices []int + done := 0 + objectCount := len(comparisonObjects) + log.Info("comparing "+strconv.Itoa(objectCount)+" audio fingerprints for duplicates...", "concurrency: "+strconv.Itoa(config.Concurrency())) + for objectCount > 0 { + object := comparisonObjects[0] + comparisonObjects = comparisonObjects[1:] + if formatMismatch { + objectFormat = path.Ext(object.path) + } + waitChannel <- struct{}{} + go func(object comparisonObject) { + tmpTimestamp := tools.LogTimestamp() + for index, comparisonObject := range comparisonObjects { + if formatMismatch && objectFormat == path.Ext(comparisonObject.path) { + continue + } + duplicate := getDuplicate(&object, &comparisonObject) + if !duplicate.isValid() { + continue + } + log.Debug("duplicate track detected", "id '"+strconv.Itoa(duplicate.id)+"', good file: "+duplicate.good+", bad file: "+duplicate.bad+", score: "+strconv.FormatFloat(duplicate.score, 'f', 2, 64)) + duplicates = append(duplicates, duplicate) + if duplicate.id == object.id { + break + } + duplicateIndices = append(duplicateIndices, index) + } + done++ + objectCount := len(comparisonObjects) + log.DebugTimed("finished comparison of the audio fingerprint for track (id: '"+strconv.Itoa(object.id)+"')", tmpTimestamp, strconv.Itoa(done)+"/"+strconv.Itoa(objectCount)) + <-waitChannel + }(object) + comparisonObjects = filterDuplicates(comparisonObjects, duplicateIndices) + duplicateIndices = nil + } + log.InfoTimed("found "+strconv.Itoa(len(duplicates))+" duplicates", timestamp) +} + +func filterDuplicates(objects []comparisonObject, duplicateIndices []int) []comparisonObject { + if len(objects) == 0 || len(duplicateIndices) == 0 { + return objects + } + timestamp := tools.LogTimestamp() + removed := 0 + var tmp []comparisonObject + for index, object := range objects { + copyObject := true + for _, value := range duplicateIndices { + if index == value { + copyObject = false + break + } + } + if !copyObject { + removed++ + continue + } + tmp = append(tmp, object) + } + log.DebugTimed("filtered "+strconv.Itoa(removed)+" duplicate track(s) from list", timestamp) + return tmp +} + +func toComparisonObject(row *sql.Rows) (comparisonObject, error) { + var comparisonObject comparisonObject + var tmp string + row.Scan(&comparisonObject.id, &comparisonObject.path, &tmp, &comparisonObject.bitrate) + fingerprint, error := metadata.FingerprintFromString(tmp) + if error != nil { + log.Error("encountered an error parsing the audio fingerprint for file '" + comparisonObject.path + "' from 'string' to '[]int32'") + return comparisonObject, error + } + comparisonObject.fingerprint = fingerprint.Value + return comparisonObject, nil +} + +func getDuplicate(object *comparisonObject, comparisonObject *comparisonObject) Duplicate { + score := metadata.CompareWith(object.fingerprint, comparisonObject.fingerprint) + if score < config.DuplicatesFingerprintThreshold() { + return Duplicate{} + } + var duplicate Duplicate + if object.bitrate > comparisonObject.bitrate { + duplicate = Duplicate{id: comparisonObject.id, good: object.path, bad: comparisonObject.path} + } else { + duplicate = Duplicate{id: object.id, good: comparisonObject.path, bad: object.path} + } + duplicate.score = score + return duplicate +} + +func (duplicate *Duplicate) isValid() bool { + return duplicate.id > 0 +} + +// struct(s) +type Duplicate struct { + id int + good string + bad string + score float64 +} + +type comparisonObject struct { + id int + path string + fingerprint []int32 + bitrate int +} diff --git a/internal/library/general.go b/internal/library/general.go new file mode 100644 index 0000000..aeef947 --- /dev/null +++ b/internal/library/general.go @@ -0,0 +1,153 @@ +package library + +import ( + "errors" + "os" + "strconv" + "strings" + "sync" + "velvettear/badger/internal/config" + "velvettear/badger/internal/database" + "velvettear/badger/internal/database/models" + "velvettear/badger/internal/log" + "velvettear/badger/internal/metadata" + "velvettear/badger/internal/tools" + + "gorm.io/gorm" +) + +// unexported function(s) +func findFiles(directory string) (files []string) { + if len(directory) == 0 { + return files + } + timestamp := tools.LogTimestamp() + formats := config.Formats() + what := strings.Join(formats, "', '") + log.Info("scanning directory '" + directory + "' for '" + what + "' files...") + files, error := tools.ScanDirectory(directory, formats...) + if error != nil { + log.Fatal("encountered an error scanning directory '"+directory+"' for '"+what+"' files", error.Error()) + } + log.InfoTimed("found "+strconv.Itoa(len(files))+"'"+what+"' files in directory '"+directory+"'", timestamp) + return files +} + +func filterChangedFiles(files []string) ([]string, []int) { + timestamp := tools.LogTimestamp() + var deletedTrackIDs []int + if len(files) == 0 { + return files, deletedTrackIDs + } + log.Info("filtering unknown or changed files...") + var connection *gorm.DB + if config.DatabaseInMemory() { + connection = database.Memory() + } else { + connection = database.File() + } + var tracks []models.Track + result := connection.Select("id", "path", "checksum", "modified", "size").Find(&tracks) + if result.RowsAffected <= 0 { + return files, deletedTrackIDs + } + var changedFiles []string + checkedPaths := make(map[string]bool) + for _, track := range tracks { + if checkedPaths[track.Path] { + continue + } + checkedPaths[track.Path] = true + changed, error := track.HasChanged() + if error != nil { + if errors.Is(error, os.ErrNotExist) { + deletedTrackIDs = append(deletedTrackIDs, track.ID) + continue + } + log.Error("encountered an error checking if file '" + track.Path + "' has changed") + continue + } + if !changed { + continue + } + changedFiles = append(changedFiles, track.Path) + } + for _, file := range files { + if checkedPaths[file] { + continue + } + checkedPaths[file] = true + changedFiles = append(changedFiles, file) + } + log.DebugTimed("filtered "+strconv.Itoa(len(changedFiles))+" unknown or changed files", timestamp) + return changedFiles, deletedTrackIDs +} + +func parseFiles(files []string, ipc chan wrapper, wait *sync.WaitGroup) { + count := len(files) + if count == 0 { + return + } + timestamp := tools.LogTimestamp() + parseWait := new(sync.WaitGroup) + parseWait.Add(count) + waitChannel := make(chan struct{}, config.Concurrency()) + done := 0 + log.Info("parsing metadata of "+strconv.Itoa(count)+" files...", "concurrency: "+strconv.Itoa(config.Concurrency())) + for index, file := range files { + waitChannel <- struct{}{} + go func(file string, last bool) { + metadata := metadata.FromFile(file) + if !metadata.Complete { + parseWait.Done() + <-waitChannel + return + } + ipc <- wrapper{metadata: metadata, last: last} + done++ + parseWait.Done() + <-waitChannel + }(file, index == count-1) + } + parseWait.Wait() + close(waitChannel) + log.InfoTimed("successfully parsed metadata of "+strconv.Itoa(done)+" files in database", timestamp) + wait.Done() +} + +func storeMetadata(ipc chan wrapper, wait *sync.WaitGroup) { + timestamp := tools.LogTimestamp() + log.Debug("starting loop to store parsed metadata...") + stored := 0 + for { + wrapper := <-ipc + error := wrapper.metadata.Store() + if error != nil { + log.Error("encountered an error storing metadata of file '" + wrapper.metadata.Path + "' in database") + } else { + stored++ + } + if wrapper.last { + break + } + } + log.InfoTimed("successfully stored metadata of "+strconv.Itoa(stored)+" files in database", timestamp) + wait.Done() +} + +func removeDeletedTracks(trackIDs []int) bool { + timestamp := tools.LogTimestamp() + if len(trackIDs) == 0 { + return false + } + result := database.Connection().Debug().Delete(&models.Track{}, trackIDs) + affectedRows := result.RowsAffected + log.InfoTimed("removed "+strconv.FormatInt(affectedRows, 10)+" deleted tracks from database", timestamp) + return affectedRows > 0 +} + +// struct(s) +type wrapper struct { + metadata metadata.Metadata + last bool +} diff --git a/internal/library/import.go b/internal/library/import.go new file mode 100644 index 0000000..cb82842 --- /dev/null +++ b/internal/library/import.go @@ -0,0 +1,85 @@ +package library + +import ( + "errors" + "io/fs" + "path" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + "velvettear/badger/internal/config" + "velvettear/badger/internal/database" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" +) + +func Import() { + timestamp := tools.LogTimestamp() + importDirectory := config.ImportDirectory() + log.Info("import of directory '" + importDirectory + "' started...") + // find files in import directory + files := findFiles(importDirectory) + if len(files) == 0 { + log.InfoTimed("import of directory '"+importDirectory+"' finished, no files were detected", timestamp) + return + } + // setup waitgroup and communication channels + ipcMove := make(chan wrapper) + ipcStore := make(chan wrapper) + wait := new(sync.WaitGroup) + wait.Add(3) + // parse the files and store the generated metadata asynchronously + go parseFiles(files, ipcMove, wait) + go moveFiles(ipcMove, ipcStore, wait) + go storeMetadata(ipcStore, wait) + wait.Wait() + close(ipcStore) + // persist the in memory database to the filesystem + database.Persist() + // clean up empty directories + deletionErrors := tools.DeleteEmpty(importDirectory) + for _, error := range deletionErrors { + dirNotEmptyError, ok := error.(*fs.PathError) + if !ok || !errors.Is(dirNotEmptyError, syscall.ENOTEMPTY) { + log.Error("encountered an error deleting directory", error.Error()) + continue + } + log.Warning("could not delete directy '" + dirNotEmptyError.Path + "' because it's not empty") + } + log.InfoTimed("import of directory '"+importDirectory+"' finished", timestamp) +} + +func moveFiles(ipcInput chan wrapper, ipcOutput chan wrapper, wait *sync.WaitGroup) { + timestamp := tools.LogTimestamp() + log.Debug("starting loop to move files according to their metadata...") + moved := 0 + for { + wrapper := <-ipcInput + target := path.Join(wrapper.metadata.GenerateTargetPath(), wrapper.metadata.GenerateFileName()) + if tools.Exists(target) { + log.Warning("file '" + wrapper.metadata.Path + "' should be moved to already existing location '" + target + "', appending timestamp") + extension := filepath.Ext("." + wrapper.metadata.Path) + target = strings.Replace(target, extension, " [badger "+strconv.FormatInt(time.Now().UnixMilli(), 10)+"]"+extension, -1) + } + // if !wrapper.metadata.ShouldBeMoved(target) { + // continue + // } + // TODO: uncomment for "real usage" + error := tools.MoveFile(wrapper.metadata.Path, target) + if error != nil { + log.Error("encountered an error moving file '"+wrapper.metadata.Path+"' to '"+target+"'", error.Error()) + continue + } + moved++ + wrapper.metadata.Path = target + ipcOutput <- wrapper + if wrapper.last { + break + } + } + log.InfoTimed("successfully moved "+strconv.Itoa(moved)+" files according to their metadata", timestamp) + wait.Done() +} diff --git a/internal/library/library.go b/internal/library/library.go new file mode 100644 index 0000000..ed39388 --- /dev/null +++ b/internal/library/library.go @@ -0,0 +1,36 @@ +package library + +import ( + "sync" + "velvettear/badger/internal/config" + "velvettear/badger/internal/database" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" +) + +// exported function(s) +func Update() { + timestamp := tools.LogTimestamp() + log.Info("database update started...") + // find and filter for unknown / changed files + files, deletedTrackIDs := filterChangedFiles(findFiles(config.LibraryDirectory())) + // if no changed files are detected and no files were deleted exit + if len(files) == 0 && !removeDeletedTracks(deletedTrackIDs) { + log.InfoTimed("database update finished, no changes were detected", timestamp) + return + } + if len(files) > 0 { + // setup waitgroup and communication channel + ipc := make(chan wrapper) + wait := new(sync.WaitGroup) + wait.Add(2) + // parse the files and store the generated metadata asynchronously + go parseFiles(files, ipc, wait) + go storeMetadata(ipc, wait) + wait.Wait() + close(ipc) + } + // persist the in memory database to the filesystem + database.Persist() + log.InfoTimed("database update finished", timestamp) +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..66bb8c4 --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,97 @@ +package log + +import ( + "fmt" + "os" + "strings" + "velvettear/badger/internal/tools" +) + +const LEVEL_DEBUG = 0 +const LEVEL_INFO = 1 +const LEVEL_WARNING = 2 +const LEVEL_ERROR = 3 +const LEVEL_FATAL = 4 + +var logLevel = 1 + +// exported functions +func SetLogLevel(level int) { + logLevel = level +} + +func Debug(message string, extras ...string) { + DebugTimed(message, -1, extras...) +} + +func DebugTimed(message string, timestamp int64, extras ...string) { + trace(LEVEL_DEBUG, timestamp, message, extras...) +} + +func Info(message string, extras ...string) { + InfoTimed(message, -1, extras...) +} + +func InfoTimed(message string, timestamp int64, extras ...string) { + trace(LEVEL_INFO, timestamp, message, extras...) +} + +func Warning(message string, extras ...string) { + WarningTimed(message, -1, extras...) +} + +func WarningTimed(message string, timestamp int64, extras ...string) { + trace(LEVEL_WARNING, timestamp, message, extras...) +} + +func Error(message string, extras ...string) { + ErrorTimed(message, -1, extras...) +} + +func ErrorTimed(message string, timestamp int64, extras ...string) { + trace(LEVEL_ERROR, -1, message, extras...) +} + +func Fatal(message string, extras ...string) { + FatalTimed(message, -1, extras...) +} + +func FatalTimed(message string, timestamp int64, extras ...string) { + trace(LEVEL_FATAL, timestamp, message, extras...) + trace(LEVEL_FATAL, -1, "exiting...") + os.Exit(1) +} + +// unexported functions +func trace(level int, timestamp int64, message string, extras ...string) { + if len(message) == 0 || level < logLevel { + return + } + suffix := strings.Join(extras, " | ") + if tools.NotEmpty(suffix) { + message += " (" + suffix + ")" + } + if timestamp >= 0 { + message += " [" + tools.LogDifferenceMilliseconds(timestamp) + "]" + } + fmt.Println(buildLogMessage(getPrefixForLogLevel(level), message)) +} + +func getPrefixForLogLevel(level int) string { + switch level { + case LEVEL_FATAL: + return "fatal" + case LEVEL_ERROR: + return "error" + case LEVEL_WARNING: + return "warning" + case LEVEL_INFO: + return "info" + default: + return "debug" + } +} + +func buildLogMessage(prefix string, message string) string { + return tools.GetFormattedDate() + " [" + prefix + "] > " + message +} diff --git a/internal/metadata/constants.go b/internal/metadata/constants.go new file mode 100644 index 0000000..bc6105f --- /dev/null +++ b/internal/metadata/constants.go @@ -0,0 +1,13 @@ +package metadata + +const METADATA_PREFIX = "format" + +const METADATA_TITLE = "title" +const METADATA_ALBUM = "album" +const METADATA_ARTIST = "artist" +const METADATA_ALBUM_ARTIST = "album_artist" +const METADATA_DATE = "date" +const METADATA_TRACK = "track" +const METADATA_DISC = "disc" +const METADATA_DURATION = "duration" +const METADATA_BITRATE = "bit_rate" diff --git a/internal/metadata/fingerprint.go b/internal/metadata/fingerprint.go new file mode 100644 index 0000000..e757351 --- /dev/null +++ b/internal/metadata/fingerprint.go @@ -0,0 +1,82 @@ +package metadata + +import ( + "strconv" + "strings" + "velvettear/badger/internal/executables" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" +) + +// exported function(s) +func FingerprintFromString(rawValue string) (Fingerprint, error) { + fingerprint := Fingerprint{RawValue: rawValue} + error := fingerprint.setValue() + return fingerprint, error +} + +func CompareWith(fingerprint []int32, comparisonFingerprint []int32) float64 { + dist := 0 + if len(fingerprint) != len(comparisonFingerprint) { + return 0 + } + for index, value := range fingerprint { + dist += strings.Count(strconv.FormatInt(int64(value^comparisonFingerprint[index]), 2), "1") + } + return 1 - float64(dist)/float64(len(fingerprint)*32) +} + +func (fingerprint *Fingerprint) CompareWith(comparison Fingerprint) float64 { + return CompareWith(fingerprint.Value, comparison.Value) +} + +// unexported function(s) +func (metadata *Metadata) generateFingerprint() { + timestamp := tools.LogTimestamp() + executable, error := executables.GetExecutable(executables.FPCALC) + if error != nil { + log.Fatal("could not get executable '"+executables.FPCALC+"'", error.Error()) + } + metadata.Fingerprint = Fingerprint{} + executable.Spawn("-raw", "-plain", metadata.Path) + result, error := executable.Spawn("-raw", "-plain", metadata.Path) + if error != nil { + log.Error("encountered an error spawning process '" + executables.FPCALC + "' for file '" + metadata.Path + "'") + return + } + metadata.Fingerprint.RawValue = strings.Trim(string(result), "\n") + error = metadata.Fingerprint.setValue() + if error != nil { + log.Error("encountered an error generating the audio fingerprint for file '"+metadata.Path+"'", error.Error()) + return + } + if metadata.Fingerprint.Value == nil { + log.Warning("generated audio fingerprint for file '"+metadata.Path+"' is empty", error.Error()) + } + log.DebugTimed("generated fingerprint for file '"+metadata.Path+"'", timestamp) +} + +func (fingerprint *Fingerprint) setValue() error { + if len(fingerprint.RawValue) == 0 { + return nil + } + var caughtError error + intArray := strings.Split(fingerprint.RawValue, ",") + elementCount := len(intArray) + fingerprint.Value = make([]int32, elementCount) + for index := 0; index < elementCount; index++ { + tmp, error := strconv.Atoi(intArray[index]) + if error != nil { + caughtError = error + tmp = 0 + } + fingerprint.Value[index] = int32(tmp) + } + return caughtError +} + +// struct(s) +type Fingerprint struct { + RawValue string + Value []int32 +} diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go new file mode 100644 index 0000000..64c8f39 --- /dev/null +++ b/internal/metadata/metadata.go @@ -0,0 +1,256 @@ +package metadata + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "velvettear/badger/internal/config" + "velvettear/badger/internal/database" + "velvettear/badger/internal/database/models" + "velvettear/badger/internal/executables" + "velvettear/badger/internal/log" + "velvettear/badger/internal/tools" + + "gorm.io/gorm" +) + +// exported function(s) +func FromFile(file string) Metadata { + timestamp := tools.LogTimestamp() + metadata := Metadata{Path: file} + executable, error := executables.GetExecutable(executables.FFPROBE) + if error != nil { + log.Fatal("could not get executable '"+executables.FFPROBE+"'", error.Error()) + } + result, error := executable.Spawn("-show_format", "-show_streams", "-loglevel", "quiet", "-print_format", "flat", file) + keys := getKeys() + resultElements := strings.Split(result, "\n") + for elementIndex := 0; elementIndex < len(resultElements); elementIndex++ { + element := strings.Trim(resultElements[elementIndex], " ") + if !strings.HasPrefix(strings.ToLower(element), METADATA_PREFIX) { + continue + } + for keyIndex := 0; keyIndex < len(keys); keyIndex++ { + key := keys[keyIndex] + cut := strings.Index(element, "=") + if cut == -1 { + continue + } + elementKey := strings.Trim(strings.ToLower(element[0:cut]), " ") + elementKey = elementKey[strings.LastIndex(elementKey, ".")+1:] + if key != elementKey { + continue + } + element = strings.Trim(strings.Trim(element[cut+1:], "\""), " ") + switch key { + case METADATA_TITLE: + metadata.Title = element + case METADATA_ALBUM: + metadata.Album = element + case METADATA_ARTIST: + metadata.Artist = element + case METADATA_ALBUM_ARTIST: + separators := []string{",", ";", "|"} + for index := 0; index < len(separators); index++ { + element = strings.ReplaceAll(element, separators[index], "/") + } + parts := strings.Split(element, "/") + for index := 0; index < len(parts); index++ { + metadata.AlbumArtist = append(metadata.AlbumArtist, strings.Trim(parts[index], " ")) + } + case METADATA_DATE: + metadata.Date = element + case METADATA_TRACK: + metadata.TrackNo = strings.Split(element, "/")[0] + case METADATA_DISC: + metadata.DiscNo = strings.Split(element, "/")[0] + case METADATA_DURATION: + metadata.Duration = element + case METADATA_BITRATE: + metadata.Bitrate, _ = strconv.Atoi(element) + } + } + } + stats, error := os.Stat(metadata.Path) + if error != nil { + log.Error("encountered an error getting the stats for file '"+metadata.Path+"'", error.Error()) + } + metadata.Modified = stats.ModTime() + metadata.Size = stats.Size() + metadata.generateFingerprint() + metadata.generateChecksum() + log.DebugTimed("parsed file '"+metadata.Path+"'", timestamp) + metadata.Complete = true + return metadata +} + +func (metadata *Metadata) GenerateTargetPath() string { + library := config.LibraryDirectory() + artist := strings.Join(metadata.AlbumArtist, " - ") + artist = strings.TrimRight(artist, " - ") + if tools.IsEmpty(artist) { + if tools.IsEmpty(metadata.Artist) { + artist = "unknown" + } else { + artist = metadata.Artist + } + } + library += string(os.PathSeparator) + artist + if tools.NotEmpty(metadata.Album) { + library += string(os.PathSeparator) + metadata.Album + } + return filepath.Clean(library) +} + +func (metadata *Metadata) GenerateFileName() string { + var filename string + if tools.NotEmpty(metadata.DiscNo) { + filename += tools.FrontFill(metadata.DiscNo, "0", 2) + } + if tools.NotEmpty(metadata.TrackNo) { + if !tools.IsEmpty(filename) { + filename += "-" + } + filename += tools.FrontFill(metadata.TrackNo, "0", 2) + } + if tools.NotEmpty(metadata.Artist) { + if tools.NotEmpty(filename) { + filename += " " + } + filename += metadata.Artist + } + if tools.NotEmpty(metadata.Title) { + if tools.NotEmpty(filename) { + filename += " - " + } + filename += metadata.Title + } + if tools.IsEmpty(filename) { + return "" + } + return filename + filepath.Ext(metadata.Path) +} + +// func (metadata *Metadata) ShouldBeMoved(target string) bool { +// if len(target) == 0 { +// return false +// } +// existing := tools.Exists(target) +// var track models.Track +// database.Connection().First(&track, models.Track{Path: target}) +// if track.ID == 0 && !existing { +// log.Debug("file '" + metadata.Path + "' should be moved to '" + target + "', no entry in database and target does not exist") +// return true +// } +// if metadata.Bitrate < track.Bitrate { +// log.Debug("file '" + metadata.Path + "' should be moved to '" + target + "', no entry in database and target does not exist") +// return false +// } +// if existing { +// error := tools.DeleteFile(track.Path) +// if error != nil { +// log.Error("encountered an error deleting the file '" + track.Path + "' in order to replace it with '" + metadata.Path + "' ") +// return false +// } +// } +// return true +// } + +func (metadata *Metadata) Store() error { + timestamp := tools.LogTimestamp() + if !metadata.Complete { + return errors.New("can not store incomplete metadata") + } + artist := models.Artist{ + Name: metadata.Artist, + } + var connection *gorm.DB + if config.DatabaseInMemory() { + connection = database.Memory() + } else { + connection = database.File() + } + connection.FirstOrCreate(&artist, artist) + if artist.ID <= 0 { + return errors.New("could not find or create artist '" + artist.Name + "' in database") + } + album := models.Album{ + Name: metadata.Album, + } + connection.FirstOrCreate(&album, album) + if album.ID <= 0 { + return errors.New("could not find or create album '" + album.Name + "' in database") + } + track := models.Track{ + Path: metadata.Path, + Title: metadata.Title, + Date: metadata.Title, + TrackNo: metadata.TrackNo, + DiscNo: metadata.DiscNo, + Duration: metadata.Duration, + Bitrate: metadata.Bitrate, + Fingerprint: metadata.Fingerprint.RawValue, + Checksum: metadata.Checksum, + Modified: metadata.Modified.UnixMilli(), + Size: metadata.Size, + ArtistID: artist.ID, + AlbumID: album.ID, + } + connection.FirstOrCreate(&track, models.Track{Path: track.Path}) + if album.ID <= 0 { + return errors.New("could not find or create track '" + track.Path + "' in database") + } + log.DebugTimed("stored parsed metadata for file '"+metadata.Path+"' in database", timestamp) + return nil +} + +// unexported function(s) +func getKeys() []string { + return []string{ + METADATA_TITLE, + METADATA_ALBUM, + METADATA_ARTIST, + METADATA_ALBUM_ARTIST, + METADATA_DATE, + METADATA_TRACK, + METADATA_DISC, + METADATA_DURATION, + METADATA_BITRATE, + } +} + +func (metadata *Metadata) generateChecksum() { + if !config.ChangeDetectionChecksum() { + return + } + timestamp := tools.LogTimestamp() + checksum, error := tools.XxHash(metadata.Path) + if error != nil { + log.Error("encountered an error generating xxhash checksum for file '"+metadata.Path+"'", error.Error()) + return + } + log.DebugTimed("generated xxhash checksum for file '"+metadata.Path+"'", timestamp) + metadata.Checksum = strconv.FormatUint(checksum, 10) +} + +// struct(s) +type Metadata struct { + Title string + Album string + Artist string + AlbumArtist []string + Date string + TrackNo string + DiscNo string + Duration string + Bitrate int + Path string + Fingerprint Fingerprint + Modified time.Time + Size int64 + Checksum string + Complete bool +} diff --git a/internal/progressbar/progressbar.go b/internal/progressbar/progressbar.go new file mode 100644 index 0000000..81fcade --- /dev/null +++ b/internal/progressbar/progressbar.go @@ -0,0 +1,77 @@ +package progressbar + +import ( + "fmt" + "velvettear/badger/internal/config" + "velvettear/badger/internal/tools" +) + +// exported function(s) +func New(start, total int64) Progressbar { + return NewGraph(start, total, "") +} + +func NewGraph(start, total int64, graph string) Progressbar { + if len(graph) == 0 { + graph = "#" + } + progressbar := Progressbar{ + current: start, + total: total, + graph: graph, + } + progressbar.setPercentage() + progressbar.setRate() + return progressbar +} + +func (progressbar *Progressbar) Step() { + progressbar.SetCurrent(progressbar.current + 1) +} + +func (progressbar *Progressbar) SetCurrent(current int64) { + if config.Debug() { + return + } + progressbar.current = current + last := progressbar.percentage + progressbar.setPercentage() + if progressbar.percentage != last { + var index int64 = 0 + for ; index < progressbar.percentage-last; index++ { + progressbar.rate += progressbar.graph + } + fmt.Printf("\r"+tools.GetFormattedDate()+"[info] > [%-50s] %3d%% | %d/%d", progressbar.rate, progressbar.percentage*2, progressbar.current, progressbar.total) + } +} + +func (progressbar *Progressbar) Finish() { + if config.Debug() { + return + } + fmt.Println() +} + +// unexported function(s) +func getPercentage(current int64, total int64) int64 { + return int64((float32(current) / float32(total)) * 50) +} + +func (progressbar *Progressbar) setPercentage() { + progressbar.percentage = getPercentage(progressbar.current, progressbar.total) +} + +func (progressbar *Progressbar) setRate() { + for index := 0; index < int(progressbar.percentage); index += 2 { + progressbar.rate += progressbar.graph + } +} + +// struct(s) +type Progressbar struct { + percentage int64 + current int64 + total int64 + rate string + graph string +} diff --git a/internal/tools/arrays.go b/internal/tools/arrays.go new file mode 100644 index 0000000..6b381ca --- /dev/null +++ b/internal/tools/arrays.go @@ -0,0 +1,11 @@ +package tools + +// exported function(s) +func ContainsString(array []string, value string) bool { + for _, tmp := range array { + if tmp == value { + return true + } + } + return false +} diff --git a/internal/tools/date.go b/internal/tools/date.go new file mode 100644 index 0000000..9468ec2 --- /dev/null +++ b/internal/tools/date.go @@ -0,0 +1,88 @@ +package tools + +import ( + "fmt" + "strconv" + "time" +) + +// exported functions +func GetDate() Date { + return newDate() +} + +func GetFormattedDate() string { + return newDate().Format() +} + +func (date Date) Format() string { + return date.Day + "." + date.Month + "." + date.Year + " " + date.Hour + ":" + date.Minute + ":" + date.Second +} + +func Timestamp() time.Time { + return time.Now() +} + +func DifferenceTime(timestamp time.Time) int { + return int(time.Since(timestamp).Milliseconds()) +} + +func DifferenceMilliseconds(timestamp time.Time) string { + return strconv.Itoa(DifferenceTime(timestamp)) + "ms" +} + +func LogTimestamp() int64 { + return time.Now().UnixMilli() +} + +func LogDifference(timestamp int64) int { + return int(time.Now().UnixMilli() - timestamp) +} + +func LogDifferenceMilliseconds(timestamp int64) string { + return strconv.Itoa(LogDifference(timestamp)) + "ms" +} + +// unexported functions +func newDate() Date { + now := time.Now() + day := fmt.Sprint(now.Day()) + if len(day) < 2 { + day = "0" + day + } + month := fmt.Sprint(int(now.Month())) + if len(month) < 2 { + month = "0" + month + } + year := fmt.Sprint(now.Year()) + hour := fmt.Sprint(now.Hour()) + if len(hour) < 2 { + hour = "0" + hour + } + minute := fmt.Sprint(now.Minute()) + if len(minute) < 2 { + minute = "0" + minute + } + second := fmt.Sprint(now.Second()) + if len(second) < 2 { + second = "0" + second + } + return Date{ + Day: day, + Month: month, + Year: year, + Hour: hour, + Minute: minute, + Second: second, + } +} + +// struct(s) +type Date struct { + Day string + Month string + Year string + Hour string + Minute string + Second string +} diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go new file mode 100644 index 0000000..237ad2a --- /dev/null +++ b/internal/tools/filesystem.go @@ -0,0 +1,195 @@ +package tools + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strconv" +) + +// exported function(s) +func ScanDirectory(source string, extensions ...string) ([]string, error) { + var files []string + if IsEmpty(source) { + return files, errors.New("no source directory specified") + } + stats, error := os.Stat(source) + if error != nil || !stats.IsDir() { + return files, error + } + results, error := os.ReadDir(source) + if error != nil { + return files, error + } + for _, file := range results { + fileName := file.Name() + filePath := filepath.Join(source, fileName) + if file.IsDir() { + subFiles, error := ScanDirectory(filePath, extensions...) + for _, subFile := range subFiles { + files = append(files, subFile) + } + if error != nil { + return files, error + } + continue + } + if ContainsString(extensions, filepath.Ext("."+fileName)) { + files = append(files, filePath) + continue + } + + } + return files, nil +} + +func MkDirs(destination string) error { + if IsEmpty(destination) { + return nil + } + destination = filepath.Clean(destination) + stats, error := os.Stat(destination) + if error != nil && !errors.Is(error, os.ErrNotExist) { + return error + } + if stats != nil && stats.IsDir() { + return nil + } + return os.MkdirAll(destination, os.ModePerm) +} + +func GetSubDirectories(directory string) []string { + var folders []string + filepath.WalkDir(directory, func(path string, info fs.DirEntry, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + return nil + } + folders = append(folders, path) + return nil + }) + return folders +} + +func CopyFile(source string, destination string) error { + if IsEmpty(source) || IsEmpty(destination) { + return nil + } + stats, error := os.Stat(source) + if error != nil { + return error + } + source = filepath.Clean(source) + destination = filepath.Clean(destination) + error = MkDirs(filepath.Dir(destination)) + if error != nil { + return error + } + bytes, error := os.ReadFile(source) + if error != nil { + return error + } + return os.WriteFile(destination, bytes, stats.Mode()) +} + +func MoveFile(source string, destination string) error { + if IsEmpty(source) || IsEmpty(destination) { + return nil + } + source = filepath.Clean(source) + destination = filepath.Clean(destination) + error := MkDirs(filepath.Dir(destination)) + if error != nil { + return error + } + error = os.Rename(source, destination) + if error == nil { + return nil + } + error = CopyFile(source, destination) + if error != nil { + return error + } + return os.Remove(source) +} + +func CreateFile(file string) error { + if Exists(file) { + return nil + } + error := MkDirs(path.Dir(file)) + if error != nil { + return error + } + _, error = os.Create(file) + if error != nil { + return error + } + return nil +} + +func Delete(source string) error { + return os.Remove(source) +} + +func DeleteForced(source string) error { + return os.RemoveAll((source)) +} + +func DeleteEmpty(source string) []error { + var errors []error + directories := GetSubDirectories(source) + for index := len(directories) - 1; index > 0; index-- { + directory := directories[index] + error := Delete(directory) + if error != nil { + errors = append(errors, error) + } + } + if len(errors) > 0 { + return errors + } + return nil +} + +func Exists(path string) bool { + _, error := os.Stat(path) + return error == nil +} + +// unexported function(s) +func getSubDirectories(directory string, folders *[]string) []string { + if IsEmpty(directory) { + return *folders + } + directory = filepath.Clean(directory) + stats, error := os.Stat(directory) + if error != nil { + return *folders + } + test := stats.IsDir() + fmt.Println(strconv.FormatBool(test)) + if !stats.IsDir() { + return *folders + } + files, error := os.ReadDir(directory) + if error != nil { + return *folders + } + for _, tmp := range files { + if !tmp.IsDir() { + continue + } + if tmp.IsDir() { + path := directory + string(os.PathSeparator) + tmp.Name() + *folders = append(*folders, path) + getSubDirectories(path, folders) + } + } + return *folders +} diff --git a/internal/tools/hash.go b/internal/tools/hash.go new file mode 100644 index 0000000..af3f384 --- /dev/null +++ b/internal/tools/hash.go @@ -0,0 +1,23 @@ +package tools + +import ( + "io" + "os" + + "github.com/cespare/xxhash" +) + +// exported functions +func XxHash(file string) (uint64, error) { + filehandle, error := os.Open(file) + if error != nil { + return 0, error + } + defer filehandle.Close() + hash := xxhash.New() + _, error = io.Copy(hash, filehandle) + if error != nil { + return 0, error + } + return xxhash.Sum64(nil), nil +} diff --git a/internal/tools/help.go b/internal/tools/help.go new file mode 100644 index 0000000..d4c141d --- /dev/null +++ b/internal/tools/help.go @@ -0,0 +1,19 @@ +package tools + +import ( + "fmt" + "os" +) + +// exported function(s) +func PrintHelp() { + fmt.Println( + "usage:\n" + + " badger [options]\n" + + "\n" + + " options:\n" + + " -h, --help display this help\n" + + " -c, --config specify the path to the config file", + ) + os.Exit(0) +} diff --git a/internal/tools/strings.go b/internal/tools/strings.go new file mode 100644 index 0000000..16e2a2e --- /dev/null +++ b/internal/tools/strings.go @@ -0,0 +1,24 @@ +package tools + +// exported function(s) +func IsEmpty(input string) bool { + return len(input) == 0 || input == "" +} + +func NotEmpty(input string) bool { + return !IsEmpty(input) +} + +func FrontFill(input string, fill string, length int) string { + for len(input) < length { + input = fill + input + } + return input +} + +func BackFill(input string, fill string, length int) string { + for len(input) < length { + input = input + fill + } + return input +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0cd6af8 --- /dev/null +++ b/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "embed" + badger "velvettear/badger/internal" +) + +// embed files in directory 'third_party' +//go:embed third_party +var embeddedFS embed.FS + +func init() { + badger.SetEmbeddedFiles(&embeddedFS) +} +func main() { + badger.Run() +} diff --git a/third_party/ffprobe b/third_party/ffprobe new file mode 100644 index 0000000..e6bd137 Binary files /dev/null and b/third_party/ffprobe differ diff --git a/third_party/fpcalc b/third_party/fpcalc new file mode 100755 index 0000000..fcdf844 Binary files /dev/null and b/third_party/fpcalc differ