Compare commits

..

No commits in common. "master" and "v0.0.5" have entirely different histories.

54 changed files with 306 additions and 4200 deletions

View File

@ -1,15 +0,0 @@
name: builder
run-name: ${{ gitea.actor }} is building
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- run: go mod tidy
- run: go build -o gspot

View File

@ -1,28 +0,0 @@
name: deployer
run-name: ${{ gitea.actor }} is releasing
on:
push:
tags:
- "*"
jobs:
go-releaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup up go
uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: nightly
args: release --clean
env:
GITEA_TOKEN: ${{ secrets.ACCESS_TOKEN_GITEA}}

3
.gitignore vendored
View File

@ -1,2 +1 @@
dist
gspot
gospt-ng

View File

@ -1,82 +0,0 @@
linters:
disable-all: true
enable:
- gofmt
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- gocritic
- bodyclose
- gosec
- prealloc
- unconvert
- unused
linters-settings:
gocritic:
# Which checks should be enabled; can't be combined with 'disabled-checks';
# See https://go-critic.github.io/overview#checks-overview
# To check which checks are enabled run `GL_DEBUG=gocritic ./build/bin/golangci-lint run`
# By default list of stable checks is used.
enabled-checks:
- ruleguard
- truncateCmp
# Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty
disabled-checks:
- captLocal
- assignOp
- paramTypeCombine
- importShadow
- commentFormatting
# Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks.
# Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags".
enabled-tags:
- performance
- diagnostic
- opinionated
disabled-tags:
- experimental
settings:
hugeParam:
# size in bytes that makes the warning trigger (default 80)
sizeThreshold: 1000
rangeValCopy:
sizeThreshold: 1024
rangeExprCopy:
# size in bytes that makes the warning trigger (default 512)
sizeThreshold: 512
# whether to check test functions (default true)
skipTestFuncs: true
truncateCmp:
# whether to skip int/uint/uintptr types (default true)
skipArchDependent: true
underef:
# whether to skip (*x).method() calls where x is a pointer receiver (default true)
skipRecvDeref: true
govet:
disable:
- deepequalerrors
- fieldalignment
- shadow
- unsafeptr
goconst:
min-len: 2
min-occurrences: 2
issues:
exclude-rules:
- linters:
- golint
text: "should be"
- linters:
- errcheck
text: "not checked"
- linters:
- staticcheck
text: "SA(1019|1029|5011)"

View File

@ -18,7 +18,7 @@ builds:
- goos: windows
goarch: "386"
ldflags:
- -s -w -X git.asdf.cafe/abs3nt/gspot/src/components/cli.Version={{.Version}}
- -s -w -X git.asdf.cafe/abs3nt/gospt-ng/src.components.cli.Version={{.Version}}
archives:
- format: tar.gz
@ -30,29 +30,31 @@ archives:
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
- goos: windows
format: zip
files:
- completions/*
checksum:
name_template: "checksums.txt"
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
groups:
- title: Added
regexp: '^.*?ADD(\([[:word:]]+\))??!?:.+$'
order: 0
- title: "Bug fixes"
regexp: '^.*?BUG(\([[:word:]]+\))??!?:.+$'
order: 1
- title: "Enhancements"
regexp: '^.*?IMPROVED(\([[:word:]]+\))??!?:.+$'
order: 1
- title: "Docs"
regexp: '^.*?DOC(\([[:word:]]+\))??!?:.+$'
order: 1
- title: "CI"
regexp: '^.*?CI(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Others
order: 999
- title: Added
regexp: '^.*?ADD(\([[:word:]]+\))??!?:.+$'
order: 0
- title: 'Bug fixes'
regexp: '^.*?BUG(\([[:word:]]+\))??!?:.+$'
order: 1
- title: 'Enhancements'
regexp: '^.*?IMPROVED(\([[:word:]]+\))??!?:.+$'
order: 1
- title: 'Docs'
regexp: '^.*?DOC(\([[:word:]]+\))??!?:.+$'
order: 1
- title: 'CI'
regexp: '^.*?CI(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Others
order: 999

14
.woodpecker.yml Normal file
View File

@ -0,0 +1,14 @@
steps:
build:
image: golang:1.22
commands:
- go mod tidy
- go build -o gospt-ng
publish:
image: goreleaser/goreleaser:nightly
commands:
- goreleaser release --clean
secrets: [ gitea_token ]
when:
event: tag

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 abs3nt
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 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.

View File

@ -1,21 +1,24 @@
build:
go build -ldflags="-X 'git.asdf.cafe/abs3nt/gspot/src/components/cli.Version=$(shell git show -s --date=short --pretty='format:%h (%ad)' HEAD)'" -o dist/ .
build: gospt-ng
run: build
./dist/gspot
gospt-ng: $(shell find . -name '*.go')
go build -o gospt-ng .
run:
go run main.go
tidy:
go mod tidy
clean:
rm -rf dist
rm -f gospt-ng
rm -rf completions
uninstall:
rm -f /usr/bin/gspot
rm -f /usr/share/zsh/site-functions/_gspot
rm -f /usr/share/bash-completion/completions/gspot
rm -f /usr/bin/gospt-ng
rm -f /usr/share/zsh/site-functions/_gospt-ng
rm -f /usr/share/bash-completion/completions/gospt-ng
rm -f /usr/share/fish/vendor_completions.d/gospt-ng.fish
install:
cp ./dist/gspot /usr/bin
cp ./completions/_gspot /usr/share/zsh/site-functions/_gspot
cp ./completions/gspot /usr/share/bash-completion/completionsgspotg
cp gospt-ng /usr/bin
cp ./completions/zsh_autocomplete /usr/share/zsh/site-functions/_gospt-ng

View File

@ -1,82 +0,0 @@
IF YOU ARE ON GITHUB.COM GO HERE INSTEAD: https://git.asdf.cafe/abs3nt/gspot
If you open an issue or PR on github I won't see it please use git.asdf.cafe. Register on asdf and open your PRs there
This project is still under heavy development and some things might not work or not work as intended. Don't hesitate to open an issue to let me know.
---
[![status-badge](https://ci.asdf.cafe/api/badges/abs3nt/gspot/status.svg)](https://ci.asdf.cafe/abs3nt/gspot)
# To install (with a package manager):
## Archlinux ([AUR])
`yay -S gspot-git`
# To build from source by pulling and building the binary
`git clone https://git.asdf.cafe/abs3nt/gspot`
`cd gspot`
`make build && sudo make install`
[AUR]: https://aur.archlinux.org/packages/gspot-git
# Configuration
go here https://developer.spotify.com/dashboard/applications to make a spotify application. you will need a client ID and a client secret. Set your redirect uri like this:
`http://localhost:8888/callback`
add your information to ~/.config/gspot/gspot.yml like this
```
client_id: "idgoeshere"
client_secret: "secretgoeshere"
port: "8888"
```
if you dont want to store your secret in the file in plaintext you can use a command to retreive it:
```
client_secret_cmd: "secret spotify_secret"
```
you should have either client_secret or client_secret_cmd
you can enable debug logging by adding
```
log_level: "debug"
log_output: "file"
```
it will log to ~/.config/gspot/gspot.log
## RUNNING
`gspot`
you will be asked to login, you will only have to do this the first time. After login you will be asked to select your default device.
helpful keybinds are shown in the bottom of the screen, hit ? to see all of them
To use the custom radio feature:
`gspot radio`
or hit ctrl+r on any track in the TUI. This will start an extended radio. To replenish the current radio run `gspot refillradio` and all the songs already listened will be removed and that number of new recomendations will be added.
This radio uses slightly different logic than the standard spotify radio to give a longer playlist and more recomendation. With a cronjob you can schedule refill to run to have an infinite and morphing radio station.
To view help:
`gspot --help`
Very open to contributations feel free to open a PR
[tmux plugin](https://git.asdf.cafe/abs3nt/tmux-gspot)
[wiki](https://git.asdf.cafe/abs3nt/gspot/wiki)

View File

@ -1,16 +0,0 @@
#compdef gspot
local -a opts
local cur
cur=${words[-1]}
if [[ "$cur" == "-"* ]]; then
opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-shell-completion)}")
else
opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-shell-completion)}")
fi
if [[ "${opts[1]}" != "" ]]; then
_describe 'values' opts
else
_files
fi

View File

@ -1,35 +0,0 @@
#! /bin/bash
: ${PROG:=$(basename ${BASH_SOURCE})}
# Macs have bash3 for which the bash-completion package doesn't include
# _init_completion. This is a minimal version of that function.
_cli_init_completion() {
COMPREPLY=()
_get_comp_words_by_ref "$@" cur prev words cword
}
_cli_bash_autocomplete() {
if [[ "${COMP_WORDS[0]}" != "source" ]]; then
local cur opts base words
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion -n "=:" || return
else
_cli_init_completion -n "=:" || return
fi
words=("${words[@]:0:$cword}")
if [[ "$cur" == "-"* ]]; then
requestComp="${words[*]} ${cur} --generate-shell-completion"
else
requestComp="${words[*]} --generate-shell-completion"
fi
opts=$(eval "${requestComp}" 2>/dev/null)
COMPREPLY=($(compgen -W "${opts}" -- ${cur}))
return 0
fi
}
complete -o bashdefault -o default -o nospace -F _cli_bash_autocomplete $PROG
unset PROG

20
completions/zsh_autocomplete Executable file
View File

@ -0,0 +1,20 @@
#compdef gospt-ng
_cli_zsh_autocomplete() {
local -a opts
local cur
cur=${words[-1]}
if [[ "$cur" == "-"* ]]; then
opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-bash-completion)}")
else
opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-bash-completion)}")
fi
if [[ "${opts[1]}" != "" ]]; then
_describe 'values' opts
else
_files
fi
}
compdef _cli_zsh_autocomplete gospt-ng

96
go.mod
View File

@ -1,82 +1,34 @@
module git.asdf.cafe/abs3nt/gspot
module git.asdf.cafe/abs3nt/gospt-ng
go 1.22.3
go 1.22.0
require (
gfx.cafe/util/go/fxplus v0.0.0-20231226111635-bc00a6a250fb
git.asdf.cafe/abs3nt/gunner v0.0.1
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.2
github.com/charmbracelet/lipgloss v0.13.1
github.com/lmittmann/tint v1.0.5
github.com/rivo/tview v0.0.0-20241016194538-c5e4fb24af13
github.com/urfave/cli/v3 v3.0.0-alpha9.1
github.com/zmb3/spotify/v2 v2.4.2
go.uber.org/fx v1.23.0
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/net v0.30.0
golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.8.0
google.golang.org/api v0.203.0
modernc.org/sqlite v1.33.1
github.com/lmittmann/tint v1.0.4
github.com/urfave/cli/v2 v2.27.1
github.com/zmb3/spotify/v2 v2.4.1
go.uber.org/fx v1.20.1
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5
)
require (
cloud.google.com/go/auth v0.9.9 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
cloud.google.com/go/compute/metadata v0.5.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.4.0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/cristalhq/aconfig v0.18.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/cristalhq/aconfig v0.18.5 // indirect
github.com/cristalhq/aconfig/aconfigdotenv v0.17.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.7.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
go.opentelemetry.io/otel v1.31.0 // indirect
go.opentelemetry.io/otel/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
go.uber.org/dig v1.18.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.35.1 // indirect
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/joho/godotenv v1.4.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.27.1 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

251
go.sum
View File

@ -13,18 +13,12 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
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/auth v0.9.9 h1:BmtbpNQozo8ZwW2t7QJjnrQtdganSdmqeIBxHxNkEZQ=
cloud.google.com/go/auth v0.9.9/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
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/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
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=
@ -37,69 +31,41 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
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=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gfx.cafe/util/go/fxplus v0.0.0-20231226111635-bc00a6a250fb h1:JL2ZB1wCxGS/mBIsTwDrgGEndHVQtZuba9dgSk+OsSE=
gfx.cafe/util/go/fxplus v0.0.0-20231226111635-bc00a6a250fb/go.mod h1:qcgf/NcKZwJCETErwNtofMa10hQtP28ec1bN2nl8ahA=
git.asdf.cafe/abs3nt/gunner v0.0.1 h1:N6kCe7fH83kzm1Sjp/5uZbl8FM5s7KoYCfmhO8qyQbA=
git.asdf.cafe/abs3nt/gunner v0.0.1/go.mod h1:Q4zhiPfmffCVAb5xIzZn6Momm91uf/deqRVd2/vdjd4=
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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm4vBfHsIc=
github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A=
github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U=
github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU=
github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
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/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cristalhq/aconfig v0.17.0/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E=
github.com/cristalhq/aconfig v0.18.6 h1:8KRBznzdjUUiaa7HeIpYbMx1uPE1/xOBEU1ajsnmNME=
github.com/cristalhq/aconfig v0.18.6/go.mod h1:9ogrGEt9yU5V4pif/ThkVUfhj8JkdV+iDeahZGgfnDU=
github.com/cristalhq/aconfig v0.18.5 h1:QqXH/Gy2c4QUQJTV2BN8UAuL/rqZ3IwhvxeC8OgzquA=
github.com/cristalhq/aconfig v0.18.5/go.mod h1:NXaRp+1e6bkO4dJn+wZ71xyaihMDYPtCSvEhMTm/H3E=
github.com/cristalhq/aconfig/aconfigdotenv v0.17.1 h1:HG2ql5fGe4FLL2fUv6o+o0YRyF1mWEcYkNfWGWD82k4=
github.com/cristalhq/aconfig/aconfigdotenv v0.17.1/go.mod h1:gQIKkh+HkVcODvMNz/cLbH65Pk9b0r4tfolCOsI8G9I=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/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=
@ -120,8 +86,8 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
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=
@ -132,11 +98,9 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -146,127 +110,72 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
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-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
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/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
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/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.0.0-20241016194538-c5e4fb24af13 h1:SG5LUOAzLU9svb9HTLJI2WnLHQDEe86fXWJ4h2fQg0s=
github.com/rivo/tview v0.0.0-20241016194538-c5e4fb24af13/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v3 v3.0.0-alpha9.1 h1:1fJU+bltkwN8lF4Sni/X0i1d8XwPIrS82ivZ8qsp/q4=
github.com/urfave/cli/v3 v3.0.0-alpha9.1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zmb3/spotify/v2 v2.4.2 h1:j3yNN5lKVEMZQItJF4MHCSZbfNWmXO+KaC+3RFaLlLc=
github.com/zmb3/spotify/v2 v2.4.2/go.mod h1:XOV7BrThayFYB9AAfB+L0Q0wyxBuLCARk4fI/ZXCBW8=
github.com/zmb3/spotify/v2 v2.4.1 h1:2ENzO3XQLOQBuxgT1Z9+PlCBSkjNgzFzmRaPns0tjM4=
github.com/zmb3/spotify/v2 v2.4.1/go.mod h1:p3r7mCCxHepzVaJOe3w1dlx9SL+T8iiQR14tfXJpuTE=
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.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw=
go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg=
go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
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=
@ -277,8 +186,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
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/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE=
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
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=
@ -301,8 +210,6 @@ 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
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=
@ -329,23 +236,19 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
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-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
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-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o=
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
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=
@ -356,8 +259,6 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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=
@ -383,27 +284,19 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w
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-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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
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=
@ -412,9 +305,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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=
@ -460,8 +351,6 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
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=
@ -482,14 +371,13 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M
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.203.0 h1:SrEeuwU3S11Wlscsn+LA1kb/Y5xT8uggJSkIhD08NAU=
google.golang.org/api v0.203.0/go.mod h1:BuOVyCSYEPwJb3npWvDnNmFI92f3GeRnHNkETneT3SI=
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 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
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=
@ -520,11 +408,6 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
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-20241015192408-796eee8c2d53 h1:Df6WuGvthPzc+JiQ/G+m+sNX24kc0aTBqoDN/0yyykE=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
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=
@ -537,9 +420,6 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
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.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
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=
@ -552,9 +432,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
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=
@ -570,32 +449,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
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=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
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=

54
main.go
View File

@ -1,36 +1,20 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"git.asdf.cafe/abs3nt/gspot/src/components/cache"
"git.asdf.cafe/abs3nt/gspot/src/components/cli"
"git.asdf.cafe/abs3nt/gspot/src/components/commands"
"git.asdf.cafe/abs3nt/gspot/src/components/logger"
"git.asdf.cafe/abs3nt/gspot/src/services"
"git.asdf.cafe/abs3nt/gospt-ng/src/app"
"git.asdf.cafe/abs3nt/gospt-ng/src/components/cli"
"git.asdf.cafe/abs3nt/gospt-ng/src/components/commands"
)
func main() {
var s fx.Shutdowner
app := fx.New(
fx.WithLogger(func(logger *slog.Logger) fxevent.Logger {
l := &fxevent.SlogLogger{Logger: logger}
l.UseLogLevel(slog.LevelDebug)
return l
}),
fx.Populate(&s),
services.Config,
app.Config,
fx.Provide(
Context,
cache.NewCache,
commands.NewCommander,
logger.NewLogger,
),
fx.Invoke(
cli.Run,
@ -38,33 +22,3 @@ func main() {
)
app.Run()
}
type AsyncInit func(func(ctx context.Context) error)
var ErrContextShutdown = errors.New("shutdown")
func Context(
lc fx.Lifecycle,
s fx.Shutdowner,
log *slog.Logger,
) (context.Context, AsyncInit) {
if log == nil {
log = slog.Default()
}
ctx, cn := context.WithCancelCause(context.Background())
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
cn(fmt.Errorf("%w: %w", context.Canceled, ErrContextShutdown))
return nil
},
})
return ctx, func(fn func(ctx context.Context) error) {
go func() {
err := fn(ctx)
if err != nil {
log.Error("Failed to run async hook", "err", err)
s.Shutdown()
}
}()
}
}

View File

@ -1,3 +0,0 @@
{
"extends": ["config:recommended"]
}

39
src/app/fx.go Normal file
View File

@ -0,0 +1,39 @@
package app
import (
"log/slog"
"os"
"gfx.cafe/util/go/fxplus"
"git.asdf.cafe/abs3nt/gunner"
"github.com/lmittmann/tint"
"go.uber.org/fx"
"git.asdf.cafe/abs3nt/gospt-ng/src/config"
"git.asdf.cafe/abs3nt/gospt-ng/src/services"
)
var Services = fx.Options(
fx.NopLogger,
fx.Provide(
func() *slog.Logger {
return slog.New(tint.NewHandler(os.Stdout, &tint.Options{
AddSource: true,
Level: slog.LevelDebug.Level(),
}))
},
services.NewSpotifyClient,
fxplus.Context,
),
)
var Config = fx.Options(
fx.Provide(
func() *config.Config {
c := &config.Config{}
gunner.LoadApp(c, "gospt")
return c
},
),
Services,
)

View File

@ -1,117 +0,0 @@
package cache
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"go.uber.org/fx"
)
type CacheEntry struct {
Expire time.Time `json:"e"`
Value string `json:"v"`
}
type CacheResult struct {
fx.Out
Cache *Cache
}
type Cache struct {
Root string
Log *slog.Logger
}
type CacheParams struct {
fx.In
Log *slog.Logger
}
func NewCache(p CacheParams) CacheResult {
c := &Cache{
Root: filepath.Join(os.TempDir(), "gspot.cache"),
Log: p.Log,
}
return CacheResult{
Cache: c,
}
}
func (c *Cache) load() (map[string]CacheEntry, error) {
out := map[string]CacheEntry{}
cache, err := os.Open(c.Root)
if err != nil {
return nil, err
}
if err := json.NewDecoder(cache).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
func (c *Cache) save(m map[string]CacheEntry) error {
payload, err := json.Marshal(m)
if err != nil {
return err
}
slog.Debug("CACHE", "saving", string(payload))
err = os.WriteFile(c.Root, payload, 0o600)
if err != nil {
return err
}
return nil
}
func (c *Cache) GetOrDo(key string, do func() (string, error), ttl time.Duration) (string, error) {
conf, err := c.load()
if err != nil {
slog.Debug("CACHE", "failed read", err)
return c.Do(key, do, ttl)
}
val, ok := conf[key]
if !ok {
return c.Do(key, do, ttl)
}
if time.Now().After(val.Expire) {
return c.Do(key, do, ttl)
}
return val.Value, nil
}
func (c *Cache) Do(key string, do func() (string, error), ttl time.Duration) (string, error) {
if do == nil {
return "", nil
}
res, err := do()
if err != nil {
return "", err
}
return c.Put(key, res, ttl)
}
func (c *Cache) Put(key string, value string, ttl time.Duration) (string, error) {
conf, err := c.load()
if err != nil {
conf = map[string]CacheEntry{}
}
conf[key] = CacheEntry{
Expire: time.Now().Add(ttl),
Value: value,
}
slog.Debug("CACHE", "new item", fmt.Sprintf("%s: %s", key, value))
err = c.save(conf)
if err != nil {
slog.Debug("CACHE", "failed to save", err)
}
return value, nil
}
func (c *Cache) Clear() error {
return os.Remove(c.Root)
}

View File

@ -1,451 +1,114 @@
package cli
import (
"context"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"github.com/urfave/cli/v3"
"github.com/zmb3/spotify/v2"
"github.com/urfave/cli/v2"
"go.uber.org/fx"
"git.asdf.cafe/abs3nt/gspot/src/components/commands"
"git.asdf.cafe/abs3nt/gspot/src/components/tui"
"git.asdf.cafe/abs3nt/gspot/src/components/tuitview"
"git.asdf.cafe/abs3nt/gospt-ng/src/components/commands"
)
var Version = "dev"
func Run(c *commands.Commander, s fx.Shutdowner) {
app := &cli.Command{
Name: "gspot",
EnableShellCompletion: true,
Version: Version,
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unknown command: %s", strings.Join(cmd.Args().Slice(), " "))
}
return tui.StartTea(c, "main")
},
defer func() {
err := s.Shutdown()
if err != nil {
slog.Error("SHUTDOWN", "error shutting down", err)
}
}()
app := &cli.App{
EnableBashCompletion: true,
Version: Version,
Commands: []*cli.Command{
{
Name: "play",
Aliases: []string{"pl", "start", "s"},
Usage: "Plays spotify",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
Action: func(cCtx *cli.Context) error {
return c.Play()
},
Category: "Playback",
},
{
Name: "playurl",
Aliases: []string{"plu"},
Usage: "Plays a spotify url",
ArgsUsage: "url",
Action: func(ctx context.Context, cmd *cli.Command) error {
if !cmd.Args().Present() {
return fmt.Errorf("no url provided")
}
if cmd.NArg() > 1 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.PlayURL(cmd.Args().First())
},
Category: "Playback",
},
{
Name: "pause",
Aliases: []string{"pa"},
Usage: "Pauses spotify",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
Action: func(cCtx *cli.Context) error {
return c.Pause()
},
Category: "Playback",
},
{
Name: "toggleplay",
Aliases: []string{"t"},
Usage: "Toggles play/pause",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
Action: func(cCtx *cli.Context) error {
return c.TogglePlay()
},
Category: "Playback",
},
{
Name: "link",
Aliases: []string{"yy"},
Usage: "Prints the current song's spotify link",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
Action: func(cCtx *cli.Context) error {
return c.PrintLink()
},
Category: "Sharing",
},
{
Name: "linkcontext",
Aliases: []string{"lc"},
Usage: "Prints the current album or playlist",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.PrintLinkContext()
Name: "next",
Aliases: []string{"n", "skip"},
Usage: "Skips to the next song",
Action: func(cCtx *cli.Context) error {
return c.Next()
},
Category: "Sharing",
},
{
Name: "youtube-link",
Aliases: []string{"yl"},
Usage: "Prints the current song's youtube link",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.PrintYoutubeLink()
},
Category: "Sharing",
},
{
Name: "next",
Aliases: []string{"n", "skip"},
Usage: "Skips to the next song",
ArgsUsage: "amount",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() > 1 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
if cmd.NArg() > 0 {
amt, err := strconv.Atoi(cmd.Args().First())
if err != nil {
return err
}
return c.Next(amt, false)
}
return c.Next(1, false)
},
Category: "Playback",
},
{
Name: "previous",
Aliases: []string{"b", "prev", "back"},
Usage: "Skips to the previous song",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
Action: func(cCtx *cli.Context) error {
return c.Previous()
},
Category: "Playback",
},
{
Name: "like",
Aliases: []string{"l"},
Usage: "Likes the current song",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
Action: func(cCtx *cli.Context) error {
return c.Like()
},
Category: "Library Management",
},
{
Name: "unlike",
Aliases: []string{"u"},
Usage: "Unlikes the current song",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
Action: func(cCtx *cli.Context) error {
return c.UnLike()
},
Category: "Library Management",
},
{
Name: "nowplaying",
Aliases: []string{"now"},
Usage: "Prints the current song",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
DefaultText: "false",
Usage: "bypass cache",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.NowPlaying(cmd.Bool("force"))
},
Category: "Info",
},
{
Name: "volume",
Aliases: []string{"v"},
Usage: "Control the volume",
Category: "Playback",
Commands: []*cli.Command{
{
Name: "up",
Usage: "Increase the volume",
ArgsUsage: "percent",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() > 1 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
amt, err := strconv.Atoi(cmd.Args().First())
if err != nil {
return err
}
return c.ChangeVolume(amt)
},
},
{
Name: "down",
Aliases: []string{"dn"},
Usage: "Decrease the volume",
ArgsUsage: "percent",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() > 1 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
amt, err := strconv.Atoi(cmd.Args().First())
if err != nil {
return err
}
return c.ChangeVolume(-amt)
},
},
{
Name: "mute",
Aliases: []string{"m"},
Usage: "Mute",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.Mute()
},
},
{
Name: "unmute",
Aliases: []string{"um"},
Usage: "Unmute",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.UnMute()
},
},
{
Name: "togglemute",
Aliases: []string{"tm"},
Usage: "Toggle mute",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.ToggleMute()
},
},
Action: func(cCtx *cli.Context) error {
return c.NowPlaying()
},
},
{
Name: "download_cover",
Usage: "Downloads the cover of the current song",
Aliases: []string{"dl"},
ArgsUsage: "path",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
if cmd.NArg() > 0 {
return
}
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() > 1 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.DownloadCover(cmd.Args().First())
},
Category: "Info",
},
{
Name: "radio",
Usage: "Starts a radio from the current song",
Aliases: []string{"r"},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.Radio()
},
Category: "Radio",
},
{
Name: "clearradio",
Usage: "Clears the radio queue",
Aliases: []string{"cr"},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.ClearRadio()
},
Category: "Radio",
},
{
Name: "refillradio",
Usage: "Refills the radio queue with similar songs",
Aliases: []string{"rr"},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.RefillRadio()
},
Category: "Radio",
},
{
Name: "status",
Usage: "Prints the current status",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.Status()
},
Category: "Info",
},
{
Name: "devices",
Usage: "Lists available devices",
Aliases: []string{"d"},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.ListDevices()
},
Category: "Info",
},
{
Name: "setdevice",
Usage: "Set the active device",
ArgsUsage: "<device_id>",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() == 0 {
return fmt.Errorf("no device id provided")
}
if cmd.NArg() > 1 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.SetDevice(spotify.ID(cmd.Args().First()))
},
Category: "Playback",
},
{
Name: "repeat",
Usage: "Toggle repeat mode",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.Repeat()
},
Category: "Playback",
},
{
Name: "shuffle",
Usage: "Toggle shuffle mode",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.Shuffle()
},
Category: "Playback",
},
{
Name: "tui",
Usage: "Starts the TUI",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return tui.StartTea(c, "main")
},
},
{
Name: "tview",
Usage: "Starts the TUI using tview (experimental)",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
// start tview tui
return tuitview.TuitView(c)
},
},
{
Name: "seek",
Usage: "Seek to a position in the song",
Aliases: []string{"sk"},
Category: "Playback",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() > 1 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
pos, err := strconv.Atoi(cmd.Args().First())
if err != nil {
return err
}
return c.SetPosition(pos)
},
Commands: []*cli.Command{
{
Name: "forward",
Aliases: []string{"f"},
Usage: "Seek forward",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.Seek(true)
},
},
{
Name: "backward",
Aliases: []string{"b"},
Usage: "Seek backward",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " "))
}
return c.Seek(false)
},
},
Args: true,
ArgsUsage: "download_cover <path>",
Action: func(cCtx *cli.Context) error {
return c.DownloadCover(cCtx.Args().First())
},
},
},
}
if err := app.Run(c.Context, os.Args); err != nil {
c.Log.Error("COMMANDER", "run error", err)
s.Shutdown(fx.ExitCode(1))
if err := app.Run(os.Args); err != nil {
slog.Error("COMMANDER", "run error", err)
os.Exit(1)
}
s.Shutdown()
}

View File

@ -1,19 +1,21 @@
package commands
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"github.com/zmb3/spotify/v2"
)
func (c *Commander) activateDevice() (spotify.ID, error) {
func (c *Commander) activateDevice(ctx context.Context) (spotify.ID, error) {
var device *spotify.PlayerDevice
configDir, _ := os.UserConfigDir()
if _, err := os.Stat(filepath.Join(configDir, "gspot/device.json")); err == nil {
deviceFile, err := os.Open(filepath.Join(configDir, "gspot/device.json"))
if _, err := os.Stat(filepath.Join(configDir, "gospt/device.json")); err == nil {
deviceFile, err := os.Open(filepath.Join(configDir, "gospt/device.json"))
if err != nil {
return "", err
}
@ -26,12 +28,12 @@ func (c *Commander) activateDevice() (spotify.ID, error) {
if err != nil {
return "", err
}
err = c.Client().TransferPlayback(c.Context, device.ID, true)
err = c.Client.TransferPlayback(ctx, device.ID, true)
if err != nil {
return "", err
}
} else {
c.Log.Error("COMMANDER", "failed to activated device", "YOU MUST RUN gspot setdevice FIRST")
slog.Error("COMMANDER", "failed to activated device", "YOU MUST RUN gospt setdevice FIRST")
}
return device.ID, nil
}

View File

@ -1,18 +0,0 @@
package commands
import (
"github.com/zmb3/spotify/v2"
)
func (c *Commander) AlbumTracks(album spotify.ID, page int) (*spotify.SimpleTrackPage, error) {
tracks, err := c.Client().
GetAlbumTracks(c.Context, album, spotify.Limit(50), spotify.Offset((page-1)*50), spotify.Market(spotify.CountryUSA))
if err != nil {
return nil, err
}
return tracks, nil
}
func (c *Commander) UserAlbums(page int) (*spotify.SavedAlbumPage, error) {
return c.Client().CurrentUsersAlbums(c.Context, spotify.Limit(50), spotify.Offset((page-1)*50))
}

View File

@ -1,12 +0,0 @@
package commands
import "github.com/zmb3/spotify/v2"
func (c *Commander) ArtistAlbums(artist spotify.ID, page int) (*spotify.SimpleAlbumPage, error) {
albums, err := c.Client().
GetArtistAlbums(c.Context, artist, []spotify.AlbumType{1, 2, 3, 4}, spotify.Market(spotify.CountryUSA), spotify.Limit(50), spotify.Offset((page-1)*50))
if err != nil {
return nil, err
}
return albums, nil
}

View File

@ -2,15 +2,9 @@ package commands
import (
"context"
"log/slog"
"sync"
"github.com/zmb3/spotify/v2"
"go.uber.org/fx"
"git.asdf.cafe/abs3nt/gspot/src/components/cache"
"git.asdf.cafe/abs3nt/gspot/src/config"
"git.asdf.cafe/abs3nt/gspot/src/services"
)
type CommanderResult struct {
@ -23,53 +17,20 @@ type CommanderParams struct {
fx.In
Context context.Context
Log *slog.Logger
Cache *cache.Cache
Config *config.Config
Client *spotify.Client
}
type Commander struct {
Context context.Context
User *spotify.PrivateUser
Log *slog.Logger
Cache *cache.Cache
mu sync.RWMutex
cl *spotify.Client
conf *config.Config
Client *spotify.Client
}
func NewCommander(p CommanderParams) CommanderResult {
c := &Commander{
Context: p.Context,
Log: p.Log,
Cache: p.Cache,
conf: p.Config,
Client: p.Client,
}
return CommanderResult{
Commander: c,
}
}
func (c *Commander) Client() *spotify.Client {
c.mu.Lock()
if c.cl == nil {
c.cl = c.connectClient()
}
c.mu.Unlock()
c.mu.RLock()
defer c.mu.RUnlock()
return c.cl
}
func (c *Commander) connectClient() *spotify.Client {
client, err := services.GetClient(c.conf)
if err != nil {
panic(err)
}
currentUser, err := client.CurrentUser(c.Context)
if err != nil {
panic(err)
}
c.User = currentUser
return client
}

View File

@ -1,53 +0,0 @@
package commands
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/zmb3/spotify/v2"
)
func (c *Commander) ListDevices() error {
devices, err := c.Client().PlayerDevices(c.Context)
if err != nil {
return err
}
return PrintDevices(devices)
}
func PrintDevices(devices []spotify.PlayerDevice) error {
out, err := json.MarshalIndent(devices, "", " ")
if err != nil {
return err
}
fmt.Println(string(out))
return nil
}
func (c *Commander) SetDevice(device spotify.ID) error {
err := c.Client().TransferPlayback(c.Context, device, true)
if err != nil {
return err
}
devices, err := c.Client().PlayerDevices(c.Context)
if err != nil {
return err
}
for _, d := range devices {
if d.ID == device {
out, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
configDir, _ := os.UserConfigDir()
err = os.WriteFile(filepath.Join(configDir, "gspot/device.json"), out, 0o600)
if err != nil {
return err
}
return nil
}
}
return fmt.Errorf("device not found")
}

View File

@ -6,11 +6,8 @@ import (
)
func (c *Commander) DownloadCover(path string) error {
if path == "" {
path = "cover.png"
}
destinationPath := filepath.Clean(path)
state, err := c.Client().PlayerState(c.Context)
state, err := c.Client.PlayerState(c.Context)
if err != nil {
return err
}

View File

@ -1,9 +1,9 @@
package commands
func (c *Commander) Like() error {
playing, err := c.Client().PlayerCurrentlyPlaying(c.Context)
playing, err := c.Client.PlayerCurrentlyPlaying(c.Context)
if err != nil {
return err
}
return c.Client().AddTracksToLibrary(c.Context, playing.Item.ID)
return c.Client.AddTracksToLibrary(c.Context, playing.Item.ID)
}

View File

@ -3,19 +3,10 @@ package commands
import "fmt"
func (c *Commander) PrintLink() error {
state, err := c.Client().PlayerState(c.Context)
state, err := c.Client.PlayerState(c.Context)
if err != nil {
return err
}
fmt.Println(state.Item.ExternalURLs["spotify"])
return nil
}
func (c *Commander) PrintLinkContext() error {
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return err
}
fmt.Println(state.PlaybackContext.ExternalURLs["spotify"])
return nil
}

View File

@ -1,117 +1,25 @@
package commands
import (
"strings"
"github.com/zmb3/spotify/v2"
)
func (c *Commander) Next(amt int, inqueue bool) error {
if inqueue {
for i := 0; i < amt; i++ {
err := c.Client().Next(c.Context)
if err != nil {
return err
}
}
return nil
}
if amt == 1 {
err := c.Client().Next(c.Context)
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().NextOpt(c.Context, &spotify.PlayOptions{
DeviceID: &deviceID,
})
if err != nil {
return err
}
}
return err
}
return nil
}
// found := false
// playingIndex := 0
current, err := c.Client().PlayerCurrentlyPlaying(c.Context)
func (c *Commander) Next() error {
err := c.Client.Next(c.Context)
if err != nil {
if isNoActiveError(err) {
deviceId, err := c.activateDevice(c.Context)
if err != nil {
return err
}
err = c.Client.NextOpt(c.Context, &spotify.PlayOptions{
DeviceID: &deviceId,
})
if err != nil {
return err
}
}
return err
}
playbackContext := current.PlaybackContext.Type
switch playbackContext {
case "playlist":
found := false
currentTrackIndex := 0
page := 1
for !found {
playlist, err := c.Client().
GetPlaylistItems(
c.Context,
spotify.ID(strings.Split(string(current.PlaybackContext.URI), ":")[2]),
spotify.Limit(50),
spotify.Offset((page-1)*50),
)
if err != nil {
return err
}
for idx, track := range playlist.Items {
if track.Track.Track.ID == current.Item.ID {
currentTrackIndex = idx + (50 * (page - 1))
found = true
break
}
}
page++
}
pos := currentTrackIndex + amt
return c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &current.PlaybackContext.URI,
PlaybackOffset: &spotify.PlaybackOffset{
Position: &pos,
},
})
case "album":
found := false
currentTrackIndex := 0
page := 1
for !found {
playlist, err := c.Client().
GetAlbumTracks(
c.Context,
spotify.ID(strings.Split(string(current.PlaybackContext.URI), ":")[2]),
spotify.Limit(50),
spotify.Offset((page-1)*50),
)
if err != nil {
return err
}
for idx, track := range playlist.Tracks {
if track.ID == current.Item.ID {
currentTrackIndex = idx + (50 * (page - 1))
found = true
break
}
}
page++
}
pos := currentTrackIndex + amt
return c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &current.PlaybackContext.URI,
PlaybackOffset: &spotify.PlaybackOffset{
Position: &pos,
},
})
default:
for i := 0; i < amt; i++ {
err := c.Client().Next(c.Context)
if err != nil {
return err
}
}
}
return nil
}

View File

@ -2,34 +2,17 @@ package commands
import (
"fmt"
"time"
"github.com/zmb3/spotify/v2"
)
func (c *Commander) NowPlaying(force bool) error {
if force {
current, err := c.Client().PlayerCurrentlyPlaying(c.Context)
if err != nil {
return err
}
str := FormatSong(current)
fmt.Println(str)
_, err = c.Cache.Put("now_playing", str, 5*time.Second)
return err
}
song, err := c.Cache.GetOrDo("now_playing", func() (string, error) {
current, err := c.Client().PlayerCurrentlyPlaying(c.Context)
if err != nil {
return "", err
}
str := FormatSong(current)
return str, nil
}, 5*time.Second)
func (c *Commander) NowPlaying() error {
current, err := c.Client.PlayerCurrentlyPlaying(c.Context)
if err != nil {
return err
}
fmt.Println(song)
str := FormatSong(current)
fmt.Println(str)
return nil
}

View File

@ -1,5 +1,5 @@
package commands
func (c *Commander) Pause() error {
return c.Client().Pause(c.Context)
return c.Client.Pause(c.Context)
}

View File

@ -1,22 +1,16 @@
package commands
import (
"fmt"
"net/url"
"strings"
"github.com/zmb3/spotify/v2"
)
import "github.com/zmb3/spotify/v2"
func (c *Commander) Play() error {
err := c.Client().Play(c.Context)
err := c.Client.Play(c.Context)
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
deviceID, err := c.activateDevice(c.Context)
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
err = c.Client.PlayOpt(c.Context, &spotify.PlayOptions{
DeviceID: &deviceID,
})
if err != nil {
@ -28,150 +22,3 @@ func (c *Commander) Play() error {
}
return nil
}
func (c *Commander) PlayLikedSongs(position int) error {
c.Log.Debug("Playing liked songs")
err := c.ClearRadio()
if err != nil {
return err
}
playlist, _, err := c.GetRadioPlaylist("Saved Songs")
if err != nil {
return err
}
c.Log.Debug("got playlist", "id", playlist.ID)
songs, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset(position))
if err != nil {
return err
}
toAdd := []spotify.ID{}
for _, song := range songs.Tracks {
toAdd = append(toAdd, song.ID)
}
_, err = c.Client().AddTracksToPlaylist(c.Context, playlist.ID, toAdd...)
if err != nil {
return err
}
c.Log.Debug("added songs to playlist")
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &playlist.URI,
})
if err != nil {
if isNoActiveError(err) {
c.Log.Debug("need to activate device")
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &playlist.URI,
DeviceID: &deviceID,
})
if err != nil {
return err
}
}
}
c.Log.Debug("starting loop")
for page := 2; page <= 5; page++ {
c.Log.Debug("doing loop", "page", page)
songs, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset((50*(page-1))+position))
if err != nil {
return err
}
toAdd := []spotify.ID{}
for _, song := range songs.Tracks {
toAdd = append(toAdd, song.ID)
}
_, err = c.Client().AddTracksToPlaylist(c.Context, playlist.ID, toAdd...)
if err != nil {
return err
}
}
c.Log.Debug("done")
return err
}
func (c *Commander) PlayURL(urlString string) error {
url, err := url.Parse(urlString)
if err != nil {
return err
}
splittUrl := strings.Split(url.Path, "/")
if len(splittUrl) < 3 {
return fmt.Errorf("invalid url")
}
trackID := splittUrl[2]
err = c.Client().QueueSong(c.Context, spotify.ID(trackID))
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().QueueSongOpt(c.Context, spotify.ID(trackID), &spotify.PlayOptions{
DeviceID: &deviceID,
})
if err != nil {
return err
}
err = c.Client().NextOpt(c.Context, &spotify.PlayOptions{
DeviceID: &deviceID,
})
if err != nil {
return err
}
return nil
} else {
return err
}
}
err = c.Client().Next(c.Context)
if err != nil {
return err
}
return nil
}
func (c *Commander) PlaySongInPlaylist(context *spotify.URI, offset *int) error {
e := c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackOffset: &spotify.PlaybackOffset{Position: offset},
PlaybackContext: context,
})
if e != nil {
if isNoActiveError(e) {
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackOffset: &spotify.PlaybackOffset{Position: offset},
PlaybackContext: context,
DeviceID: &deviceID,
})
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackOffset: &spotify.PlaybackOffset{Position: offset},
PlaybackContext: context,
DeviceID: &deviceID,
})
if err != nil {
return err
}
}
}
err = c.Client().Play(c.Context)
if err != nil {
return err
}
} else {
return e
}
}
return nil
}

View File

@ -1,23 +0,0 @@
package commands
import "github.com/zmb3/spotify/v2"
func (c *Commander) Playlists(page int) (*spotify.SimplePlaylistPage, error) {
return c.Client().CurrentUsersPlaylists(c.Context, spotify.Limit(50), spotify.Offset((page-1)*50))
}
func (c *Commander) PlaylistTracks(playlist spotify.ID, page int) (*spotify.PlaylistItemPage, error) {
return c.Client().GetPlaylistItems(c.Context, playlist, spotify.Limit(50), spotify.Offset((page-1)*50))
}
func (c *Commander) DeleteTracksFromPlaylist(tracks []spotify.ID, playlist spotify.ID) error {
_, err := c.Client().RemoveTracksFromPlaylist(c.Context, playlist, tracks...)
if err != nil {
return err
}
return nil
}
func (c *Commander) TrackList(page int) (*spotify.SavedTrackPage, error) {
return c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset((page-1)*50))
}

View File

@ -5,15 +5,15 @@ import (
)
func (c *Commander) Previous() error {
err := c.Client().Previous(c.Context)
err := c.Client.Previous(c.Context)
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
deviceId, err := c.activateDevice(c.Context)
if err != nil {
return err
}
err = c.Client().PreviousOpt(c.Context, &spotify.PlayOptions{
DeviceID: &deviceID,
err = c.Client.PreviousOpt(c.Context, &spotify.PlayOptions{
DeviceID: &deviceId,
})
if err != nil {
return err

View File

@ -1,25 +0,0 @@
package commands
import "github.com/zmb3/spotify/v2"
func (c *Commander) QueueSong(id spotify.ID) error {
err := c.Client().QueueSong(c.Context, id)
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().QueueSongOpt(c.Context, id, &spotify.PlayOptions{
DeviceID: &deviceID,
})
if err != nil {
return err
}
return nil
} else {
return err
}
}
return nil
}

View File

@ -1,631 +0,0 @@
package commands
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"math/rand"
"os"
"path/filepath"
"time"
"github.com/zmb3/spotify/v2"
_ "modernc.org/sqlite"
)
func (c *Commander) Radio() error {
currentSong, err := c.Client().PlayerCurrentlyPlaying(c.Context)
if err != nil {
return err
}
if currentSong.Item != nil {
return c.RadioGivenSong(currentSong.Item.SimpleTrack, currentSong.Progress)
}
_, err = c.activateDevice()
if err != nil {
return err
}
tracks, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(10))
if err != nil {
return err
}
return c.RadioGivenSong(tracks.Tracks[rand.Intn(len(tracks.Tracks))].SimpleTrack, 0)
}
func (c *Commander) RadioFromPlaylist(playlist spotify.SimplePlaylist) error {
playlistPage, err := c.Client().GetPlaylistItems(
c.Context,
playlist.ID,
spotify.Limit(50),
spotify.Offset(0),
)
if err != nil {
return err
}
pageSongs := playlistPage.Items
rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] })
seedCount := 5
if len(pageSongs) < seedCount {
seedCount = len(pageSongs)
}
seedIds := []spotify.ID{}
for idx, song := range pageSongs {
if idx >= seedCount {
break
}
seedIds = append(seedIds, song.Track.Track.ID)
}
return c.RadioGivenList(seedIds[:seedCount], playlist.Name)
}
func (c *Commander) RadioFromSavedTracks() error {
savedSongs, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset(0))
if err != nil {
return err
}
if savedSongs.Total == 0 {
return fmt.Errorf("you have no saved songs")
}
pages := int(math.Ceil(float64(savedSongs.Total) / 50))
randomPage := 1
if pages > 1 {
randomPage = rand.Intn(pages-1) + 1
}
trackPage, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset(randomPage*50))
if err != nil {
return err
}
pageSongs := trackPage.Tracks
rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] })
seedCount := 4
seedIds := []spotify.ID{}
for idx, song := range pageSongs {
if idx >= seedCount {
break
}
seedIds = append(seedIds, song.ID)
}
seedIds = append(seedIds, savedSongs.Tracks[0].ID)
return c.RadioGivenList(seedIds, "Saved Tracks")
}
func (c *Commander) RadioGivenArtist(artist spotify.SimpleArtist) error {
seed := spotify.Seeds{
Artists: []spotify.ID{artist.ID},
}
recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100))
if err != nil {
return err
}
recomendationIds := []spotify.ID{}
for _, song := range recomendations.Tracks {
recomendationIds = append(recomendationIds, song.ID)
}
err = c.ClearRadio()
if err != nil {
return err
}
radioPlaylist, db, err := c.GetRadioPlaylist(artist.Name)
if err != nil {
return err
}
queue := []spotify.ID{}
for _, rec := range recomendationIds {
exists, err := c.SongExists(db, rec)
if err != nil {
return err
}
if !exists {
_, err := db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(rec)))
if err != nil {
return err
}
queue = append(queue, rec)
}
}
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, queue...)
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &radioPlaylist.URI,
})
if err != nil {
return err
}
err = c.Client().Repeat(c.Context, "context")
if err != nil {
return err
}
for i := 0; i < 4; i++ {
id := rand.Intn(len(recomendationIds)-2) + 1
seed := spotify.Seeds{
Tracks: []spotify.ID{recomendationIds[id]},
}
additionalRecs, err := c.Client().
GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100))
if err != nil {
return err
}
additionalRecsIds := []spotify.ID{}
for _, song := range additionalRecs.Tracks {
exists, err := c.SongExists(db, song.ID)
if err != nil {
return err
}
if !exists {
_, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID)))
if err != nil {
return err
}
additionalRecsIds = append(additionalRecsIds, song.ID)
}
}
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, additionalRecsIds...)
if err != nil {
return err
}
}
return nil
}
func (c *Commander) RadioGivenSong(song spotify.SimpleTrack, pos spotify.Numeric) error {
start := time.Now().UnixMilli()
seed := spotify.Seeds{
Tracks: []spotify.ID{song.ID},
}
recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(99))
if err != nil {
return err
}
recomendationIds := []spotify.ID{}
for _, song := range recomendations.Tracks {
recomendationIds = append(recomendationIds, song.ID)
}
err = c.ClearRadio()
if err != nil {
return err
}
radioPlaylist, db, err := c.GetRadioPlaylist(song.Name)
if err != nil {
return err
}
_, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID)))
if err != nil {
return err
}
queue := []spotify.ID{song.ID}
for _, rec := range recomendationIds {
exists, err := c.SongExists(db, rec)
if err != nil {
return err
}
if !exists {
_, err := db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(rec)))
if err != nil {
return err
}
queue = append(queue, rec)
}
}
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, queue...)
if err != nil {
return err
}
delay := time.Now().UnixMilli() - start
if pos != 0 {
pos = pos + spotify.Numeric(delay)
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &radioPlaylist.URI,
PositionMs: pos,
})
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &radioPlaylist.URI,
DeviceID: &deviceID,
PositionMs: pos,
})
if err != nil {
return err
}
}
}
err = c.Client().Repeat(c.Context, "context")
if err != nil {
return err
}
for i := 0; i < 4; i++ {
id := rand.Intn(len(recomendationIds)-2) + 1
seed := spotify.Seeds{
Tracks: []spotify.ID{recomendationIds[id]},
}
additionalRecs, err := c.Client().
GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100))
if err != nil {
return err
}
additionalRecsIds := []spotify.ID{}
for _, song := range additionalRecs.Tracks {
exists, err := c.SongExists(db, song.ID)
if err != nil {
return err
}
if !exists {
_, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID)))
if err != nil {
return err
}
additionalRecsIds = append(additionalRecsIds, song.ID)
}
}
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, additionalRecsIds...)
if err != nil {
return err
}
}
return nil
}
func (c *Commander) ClearRadio() error {
radioPlaylist, db, err := c.GetRadioPlaylist("")
if err != nil {
return err
}
err = c.Client().UnfollowPlaylist(c.Context, radioPlaylist.ID)
if err != nil {
return err
}
_, _ = db.Query("DROP TABLE IF EXISTS radio")
configDir, _ := os.UserConfigDir()
os.Remove(filepath.Join(configDir, "gspot/radio.json"))
_ = c.Client().Pause(c.Context)
return nil
}
func (c *Commander) GetRadioPlaylist(name string) (*spotify.FullPlaylist, *sql.DB, error) {
configDir, _ := os.UserConfigDir()
playlistFile, err := os.ReadFile(filepath.Join(configDir, "gspot/radio.json"))
if errors.Is(err, os.ErrNotExist) {
return c.CreateRadioPlaylist(name)
}
if err != nil {
return nil, nil, err
}
var playlist *spotify.FullPlaylist
err = json.Unmarshal(playlistFile, &playlist)
if err != nil {
return nil, nil, err
}
db, err := sql.Open("sqlite", filepath.Join(configDir, "gspot/radio.db"))
if err != nil {
return nil, nil, err
}
return playlist, db, nil
}
func (c *Commander) CreateRadioPlaylist(name string) (*spotify.FullPlaylist, *sql.DB, error) {
// private flag doesnt work
configDir, _ := os.UserConfigDir()
playlist, err := c.Client().
CreatePlaylistForUser(c.Context, c.User.ID, name+" - autoradio", "Automanaged radio playlist", false, false)
if err != nil {
return nil, nil, err
}
raw, err := json.MarshalIndent(playlist, "", " ")
if err != nil {
return nil, nil, err
}
err = os.WriteFile(filepath.Join(configDir, "gspot/radio.json"), raw, 0o600)
if err != nil {
return nil, nil, err
}
db, err := sql.Open("sqlite", filepath.Join(configDir, "gspot/radio.db"))
if err != nil {
return nil, nil, err
}
_, _ = db.QueryContext(c.Context, "DROP TABLE IF EXISTS radio")
_, _ = db.QueryContext(c.Context, "CREATE TABLE IF NOT EXISTS radio (id string PRIMARY KEY)")
return playlist, db, nil
}
func (c *Commander) SongExists(db *sql.DB, song spotify.ID) (bool, error) {
songID := string(song)
sqlStmt := `SELECT id FROM radio WHERE id = ?`
err := db.QueryRow(sqlStmt, songID).Scan(&songID)
if err != nil {
if err != sql.ErrNoRows {
return false, err
}
return false, nil
}
return true, nil
}
func (c *Commander) RefillRadio() error {
status, err := c.Client().PlayerCurrentlyPlaying(c.Context)
if err != nil {
return err
}
paused := false
if !status.Playing {
paused = true
}
toRemove := []spotify.ID{}
radioPlaylist, db, err := c.GetRadioPlaylist("")
if err != nil {
return err
}
playlistItems, err := c.Client().GetPlaylistItems(c.Context, radioPlaylist.ID)
if err != nil {
return fmt.Errorf("orig playlist items: %w", err)
}
if status.PlaybackContext.URI != radioPlaylist.URI || paused {
return c.RadioFromPlaylist(radioPlaylist.SimplePlaylist)
}
page := 0
for {
tracks, err := c.Client().GetPlaylistItems(c.Context, radioPlaylist.ID, spotify.Limit(50), spotify.Offset(page*50))
if err != nil {
return fmt.Errorf("tracks: %w", err)
}
if len(tracks.Items) == 0 {
break
}
for _, track := range tracks.Items {
if track.Track.Track.ID == status.Item.ID {
break
}
toRemove = append(toRemove, track.Track.Track.ID)
}
page++
}
if len(toRemove) > 0 {
var trackGroups []spotify.ID
for idx, item := range toRemove {
if idx%100 == 0 && idx != 0 {
_, err = c.Client().RemoveTracksFromPlaylist(c.Context, radioPlaylist.ID, trackGroups...)
trackGroups = []spotify.ID{}
}
trackGroups = append(trackGroups, item)
if err != nil {
return fmt.Errorf("error clearing playlist: %w", err)
}
}
_, err := c.Client().RemoveTracksFromPlaylist(c.Context, radioPlaylist.ID, trackGroups...)
if err != nil {
return err
}
}
toAdd := 500 - (int(playlistItems.Total) - len(toRemove))
playlistItems, err = c.Client().GetPlaylistItems(c.Context, radioPlaylist.ID)
if err != nil {
return fmt.Errorf("playlist items: %w", err)
}
total := playlistItems.Total
pages := int(math.Ceil(float64(total) / 50))
randomPage := 1
if pages > 1 {
randomPage = rand.Intn(pages-1) + 1
}
playlistPage, err := c.Client().
GetPlaylistItems(c.Context, radioPlaylist.ID, spotify.Limit(50), spotify.Offset((randomPage-1)*50))
if err != nil {
return fmt.Errorf("playlist page: %w", err)
}
pageSongs := playlistPage.Items
rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] })
seedCount := 5
if len(pageSongs) < seedCount {
seedCount = len(pageSongs)
}
seedIds := []spotify.ID{}
for idx, song := range pageSongs {
if idx >= seedCount {
break
}
seedIds = append(seedIds, song.Track.Track.ID)
}
seed := spotify.Seeds{
Tracks: seedIds,
}
recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(95))
if err != nil {
return err
}
recomendationIds := []spotify.ID{}
for _, song := range recomendations.Tracks {
exists, err := c.SongExists(db, song.ID)
if err != nil {
return fmt.Errorf("err check song existnce: %w", err)
}
if !exists {
recomendationIds = append(recomendationIds, song.ID)
}
}
queue := []spotify.ID{}
for idx, rec := range recomendationIds {
if idx > toAdd {
break
}
_, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", rec.String()))
if err != nil {
return err
}
queue = append(queue, rec)
}
toAdd -= len(queue)
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, queue...)
if err != nil {
return fmt.Errorf("add tracks: %w", err)
}
err = c.Client().Repeat(c.Context, "context")
if err != nil {
return fmt.Errorf("repeat: %w", err)
}
for toAdd > 0 {
id := rand.Intn(len(recomendationIds)-2) + 1
seed := spotify.Seeds{
Tracks: []spotify.ID{recomendationIds[id]},
}
additionalRecs, err := c.Client().
GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100))
if err != nil {
return fmt.Errorf("get recs: %w", err)
}
additionalRecsIds := []spotify.ID{}
for idx, song := range additionalRecs.Tracks {
exists, err := c.SongExists(db, song.ID)
if err != nil {
return fmt.Errorf("check song existence: %w", err)
}
if !exists {
if idx > toAdd {
break
}
additionalRecsIds = append(additionalRecsIds, song.ID)
queue = append(queue, song.ID)
}
}
toAdd -= len(queue)
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, additionalRecsIds...)
if err != nil {
return fmt.Errorf("add tracks to playlist: %w", err)
}
}
return nil
}
func (c *Commander) RadioFromAlbum(album spotify.SimpleAlbum) error {
tracks, err := c.AlbumTracks(album.ID, 1)
if err != nil {
return err
}
total := tracks.Total
if total == 0 {
return fmt.Errorf("this playlist is empty")
}
pages := int(math.Ceil(float64(total) / 50))
randomPage := 1
if pages > 1 {
randomPage = rand.Intn(pages-1) + 1
}
albumTrackPage, err := c.AlbumTracks(album.ID, randomPage)
if err != nil {
return err
}
pageSongs := albumTrackPage.Tracks
rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] })
seedCount := 5
if len(pageSongs) < seedCount {
seedCount = len(pageSongs)
}
seedIds := []spotify.ID{}
for idx, song := range pageSongs {
if idx >= seedCount {
break
}
seedIds = append(seedIds, song.ID)
}
return c.RadioGivenList(seedIds[:seedCount], album.Name)
}
func (c *Commander) RadioGivenList(songs []spotify.ID, name string) error {
seed := spotify.Seeds{
Tracks: songs,
}
recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(99))
if err != nil {
return err
}
recomendationIds := []spotify.ID{}
for _, song := range recomendations.Tracks {
recomendationIds = append(recomendationIds, song.ID)
}
err = c.ClearRadio()
if err != nil {
return err
}
radioPlaylist, db, err := c.GetRadioPlaylist(name)
if err != nil {
return err
}
queue := []spotify.ID{songs[0]}
for _, rec := range recomendationIds {
exists, err := c.SongExists(db, rec)
if err != nil {
return err
}
if !exists {
_, err := db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(rec)))
if err != nil {
return err
}
queue = append(queue, rec)
}
}
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, queue...)
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &radioPlaylist.URI,
})
if err != nil {
if isNoActiveError(err) {
deviceID, err := c.activateDevice()
if err != nil {
return err
}
err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &radioPlaylist.URI,
DeviceID: &deviceID,
})
if err != nil {
return err
}
}
}
for i := 0; i < 4; i++ {
id := rand.Intn(len(recomendationIds)-2) + 1
seed := spotify.Seeds{
Tracks: []spotify.ID{recomendationIds[id]},
}
additionalRecs, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100))
if err != nil {
return err
}
additionalRecsIds := []spotify.ID{}
for _, song := range additionalRecs.Tracks {
exists, err := c.SongExists(db, song.ID)
if err != nil {
return err
}
if !exists {
_, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID)))
if err != nil {
return err
}
additionalRecsIds = append(additionalRecsIds, song.ID)
}
}
_, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, additionalRecsIds...)
if err != nil {
return err
}
}
return nil
}

View File

@ -1,19 +0,0 @@
package commands
func (c *Commander) Repeat() error {
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return err
}
newState := "off"
if state.RepeatState == "off" {
newState = "context"
}
// spotifyd only supports binary value for repeat, context or off, change when/if spotifyd is better
err = c.Client().Repeat(c.Context, newState)
if err != nil {
return err
}
c.Log.Info("COMMANDER", "Repeat set to", newState)
return nil
}

View File

@ -1,12 +0,0 @@
package commands
import "github.com/zmb3/spotify/v2"
func (c *Commander) Search(search string, page int) (*spotify.SearchResult, error) {
result, err := c.Client().
Search(c.Context, search, spotify.SearchTypeAlbum|spotify.SearchTypeArtist|spotify.SearchTypeTrack|spotify.SearchTypePlaylist, spotify.Limit(50), spotify.Offset((page-1)*50))
if err != nil {
return nil, err
}
return result, nil
}

View File

@ -1,25 +0,0 @@
package commands
func (c *Commander) Seek(fwd bool) error {
current, err := c.Client().PlayerCurrentlyPlaying(c.Context)
if err != nil {
return err
}
newPos := current.Progress + 5000
if !fwd {
newPos = current.Progress - 5000
}
err = c.Client().Seek(c.Context, int(newPos))
if err != nil {
return err
}
return nil
}
func (c *Commander) SetPosition(pos int) error {
err := c.Client().Seek(c.Context, pos)
if err != nil {
return err
}
return nil
}

View File

@ -1,14 +0,0 @@
package commands
func (c *Commander) Shuffle() error {
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return err
}
err = c.Client().Shuffle(c.Context, !state.ShuffleState)
if err != nil {
return err
}
c.Log.Info("COMMANDER", "shuffle state", !state.ShuffleState)
return nil
}

View File

@ -1,38 +0,0 @@
package commands
import (
"encoding/json"
"fmt"
"time"
"github.com/zmb3/spotify/v2"
)
func (c *Commander) Status() error {
state, err := c.Cache.GetOrDo("state", func() (string, error) {
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return "", err
}
str, err := c.FormatState(state)
if err != nil {
return "", nil
}
return str, nil
}, 5*time.Second)
if err != nil {
return err
}
fmt.Println(state)
return nil
}
func (c *Commander) FormatState(state *spotify.PlayerState) (string, error) {
state.Item.AvailableMarkets = []string{}
state.Item.Album.AvailableMarkets = []string{}
out, err := json.MarshalIndent(state, "", " ")
if err != nil {
return "", err
}
return (string(out)), nil
}

View File

@ -1,12 +1,12 @@
package commands
func (c *Commander) TogglePlay() error {
state, err := c.Client().PlayerState(c.Context)
state, err := c.Client.PlayerState(c.Context)
if err != nil {
return c.Play()
return err
}
if state.Playing {
return c.Pause()
return c.Client.Pause(c.Context)
}
return c.Play()
return c.Client.Play(c.Context)
}

View File

@ -1,9 +1,9 @@
package commands
func (c *Commander) UnLike() error {
playing, err := c.Client().PlayerCurrentlyPlaying(c.Context)
playing, err := c.Client.PlayerCurrentlyPlaying(c.Context)
if err != nil {
return err
}
return c.Client().RemoveTracksFromLibrary(c.Context, playing.Item.ID)
return c.Client.RemoveTracksFromLibrary(c.Context, playing.Item.ID)
}

View File

@ -1,35 +0,0 @@
package commands
func (c *Commander) ChangeVolume(amount int) error {
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return err
}
newVolume := int(state.Device.Volume) + amount
if newVolume > 100 {
newVolume = 100
}
if newVolume < 0 {
newVolume = 0
}
return c.Client().Volume(c.Context, newVolume)
}
func (c *Commander) Mute() error {
return c.ChangeVolume(-100)
}
func (c *Commander) UnMute() error {
return c.ChangeVolume(100)
}
func (c *Commander) ToggleMute() error {
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return err
}
if state.Device.Volume == 0 {
return c.ChangeVolume(100)
}
return c.ChangeVolume(-100)
}

View File

@ -1,17 +0,0 @@
package commands
import (
"fmt"
"git.asdf.cafe/abs3nt/gspot/src/components/youtube"
)
func (c *Commander) PrintYoutubeLink() error {
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return err
}
link := youtube.Search(state.Item.Artists[0].Name + state.Item.Name)
fmt.Println(link)
return nil
}

View File

@ -1,67 +0,0 @@
package logger
import (
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/lmittmann/tint"
"go.uber.org/fx"
"git.asdf.cafe/abs3nt/gspot/src/config"
)
type LoggerResult struct {
fx.Out
Logger *slog.Logger
}
type LoggerParams struct {
fx.In
Config *config.Config
}
func NewLogger(p LoggerParams) LoggerResult {
lvl := slog.LevelInfo
configLevel := strings.ToUpper(p.Config.LogLevel)
switch configLevel {
case "INFO":
lvl = slog.LevelInfo
case "WARN":
lvl = slog.LevelWarn
case "ERROR":
lvl = slog.LevelError
case "DEBUG":
lvl = slog.LevelDebug
}
if strings.ToUpper(p.Config.LogOutput) == "FILE" {
fp := ""
p, err := os.UserConfigDir()
if err != nil {
p, err := os.UserHomeDir()
if err != nil {
os.Exit(1)
}
fp = filepath.Join(p, ".config", "gspot", "gspot.log")
} else {
fp = filepath.Join(p, "gspot", "gspot.log")
}
f, err := os.Create(fp)
if err != nil {
os.Exit(1)
}
return LoggerResult{
Logger: slog.New(slog.NewJSONHandler(f, &slog.HandlerOptions{
Level: lvl.Level(),
})),
}
}
return LoggerResult{
Logger: slog.New(tint.NewHandler(os.Stdout, &tint.Options{
Level: lvl.Level(),
TimeFormat: "[15:04:05.000]",
})),
}
}

View File

@ -1,165 +0,0 @@
package tui
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/zmb3/spotify/v2"
)
func (m *mainModel) LoadMoreItems() {
loading = true
defer func() {
page++
loading = false
}()
switch m.mode {
case "artist":
albums, err := m.commands.ArtistAlbums(m.artist.ID, (page + 1))
if err != nil {
return
}
items := []list.Item{}
for _, album := range albums.Albums {
items = append(items, mainItem{
Name: album.Name,
ID: album.ID,
Desc: fmt.Sprintf("%s by %s", album.AlbumType, album.Artists[0].Name),
SpotifyItem: album,
})
}
for _, item := range items {
m.list.InsertItem(len(m.list.Items())+1, item)
}
mainUpdates <- m
return
case "artists":
artists, err := m.commands.Client().CurrentUsersFollowedArtists(
m.commands.Context,
spotify.Limit(50),
spotify.Offset((page)*50),
)
if err != nil {
return
}
items := []list.Item{}
for _, artist := range artists.Artists {
items = append(items, mainItem{
Name: artist.Name,
ID: artist.ID,
Desc: fmt.Sprintf(
"%d followers, genres: %s, popularity: %d",
artist.Followers.Count,
artist.Genres,
artist.Popularity,
),
SpotifyItem: artist.SimpleArtist,
})
}
for _, item := range items {
m.list.InsertItem(len(m.list.Items())+1, item)
}
mainUpdates <- m
return
case "album":
tracks, err := m.commands.AlbumTracks(m.album.ID, (page + 1))
if err != nil {
return
}
items := []mainItem{}
for _, track := range tracks.Tracks {
items = append(items, mainItem{
Name: track.Name,
Artist: track.Artists[0],
Duration: track.TimeDuration().Round(time.Second).String(),
ID: track.ID,
Desc: track.Artists[0].Name + " - " + track.TimeDuration().Round(time.Second).String(),
})
}
for _, item := range items {
m.list.InsertItem(len(m.list.Items())+1, item)
}
mainUpdates <- m
return
case "albums":
albums, err := m.commands.UserAlbums(page + 1)
if err != nil {
return
}
items := []list.Item{}
for _, album := range albums.Albums {
items = append(items, mainItem{
Name: album.Name,
ID: album.ID,
Desc: fmt.Sprintf("%s, %d tracks", album.Artists[0].Name, album.Tracks.Total),
SpotifyItem: album.SimpleAlbum,
})
}
for _, item := range items {
m.list.InsertItem(len(m.list.Items())+1, item)
}
mainUpdates <- m
return
case "main":
playlists, err := m.commands.Playlists(page + 1)
if err != nil {
return
}
items := []list.Item{}
for _, playlist := range playlists.Playlists {
items = append(items, mainItem{
Name: playlist.Name,
Desc: playlist.Description,
SpotifyItem: playlist,
})
}
for _, item := range items {
m.list.InsertItem(len(m.list.Items())+1, item)
}
mainUpdates <- m
return
case "playlist":
playlistItems, err := m.commands.PlaylistTracks(m.playlist.ID, (page + 1))
if err != nil {
return
}
items := []mainItem{}
for _, item := range playlistItems.Items {
items = append(items, mainItem{
Name: item.Track.Track.Name,
Artist: item.Track.Track.Artists[0],
Duration: item.Track.Track.TimeDuration().Round(time.Second).String(),
ID: item.Track.Track.ID,
Desc: item.Track.Track.Artists[0].Name + " - " + item.Track.Track.TimeDuration().
Round(time.Second).
String(),
})
}
for _, item := range items {
m.list.InsertItem(len(m.list.Items())+1, item)
}
mainUpdates <- m
return
case "tracks":
tracks, err := m.commands.TrackList(page + 1)
if err != nil {
return
}
items := []list.Item{}
for _, track := range tracks.Tracks {
items = append(items, mainItem{
Name: track.Name,
Artist: track.Artists[0],
Duration: track.TimeDuration().Round(time.Second).String(),
ID: track.ID,
Desc: track.Artists[0].Name + " - " + track.TimeDuration().Round(time.Second).String(),
})
}
for _, item := range items {
m.list.InsertItem(len(m.list.Items())+1, item)
}
mainUpdates <- m
return
}
}

View File

@ -1,937 +0,0 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/zmb3/spotify/v2"
"git.asdf.cafe/abs3nt/gspot/src/components/commands"
)
var (
P *tea.Program
DocStyle = lipgloss.NewStyle().Margin(0, 2).Border(lipgloss.DoubleBorder(), true, true, true, true)
currentlyPlaying *spotify.CurrentlyPlaying
playbackContext string
mainUpdates chan *mainModel
page = 1
loading = false
showingMessage = false
)
type Mode string
const (
Album Mode = "album"
ArtistAlbum Mode = "artistalbum"
Artist Mode = "artist"
Artists Mode = "artists"
Queue Mode = "queue"
Tracks Mode = "tracks"
Albums Mode = "albums"
Main Mode = "main"
Playlists Mode = "playlists"
Playlist Mode = "playlist"
Devices Mode = "devices"
Search Mode = "search"
SearchAlbums Mode = "searchalbums"
SearchAlbum Mode = "searchalbum"
SearchArtists Mode = "searchartists"
SearchArtist Mode = "searchartist"
SearchArtistAlbum Mode = "searchartistalbum"
SearchTracks Mode = "searchtracks"
SearchPlaylists Mode = "searchplaylsits"
SearchPlaylist Mode = "searchplaylist"
)
type mainItem struct {
Name string
Duration string
Artist spotify.SimpleArtist
ID spotify.ID
Desc string
SpotifyItem any
}
type SearchResults struct {
Tracks *spotify.FullTrackPage
Artists *spotify.FullArtistPage
Playlists *spotify.SimplePlaylistPage
Albums *spotify.SimpleAlbumPage
}
func (i mainItem) Title() string { return i.Name }
func (i mainItem) Description() string { return i.Desc }
func (i mainItem) FilterValue() string { return i.Title() + i.Desc }
type mainModel struct {
list list.Model
input textinput.Model
commands *commands.Commander
mode Mode
playlist spotify.SimplePlaylist
artist spotify.SimpleArtist
album spotify.SimpleAlbum
searchResults *SearchResults
progress progress.Model
playing *spotify.CurrentlyPlaying
playbackContext string
search string
}
func (m *mainModel) PlayRadio() {
go m.SendMessage("Starting radio for "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
selectedItem := m.list.SelectedItem().(mainItem).SpotifyItem
switch item := selectedItem.(type) {
case spotify.SimplePlaylist:
go func() {
err := m.commands.RadioFromPlaylist(item)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case *spotify.SavedTrackPage:
go func() {
err := m.commands.RadioFromSavedTracks()
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.SimpleAlbum:
go func() {
err := m.commands.RadioFromAlbum(item)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.FullAlbum:
go func() {
err := m.commands.RadioFromAlbum(item.SimpleAlbum)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.SimpleArtist:
go func() {
err := m.commands.RadioGivenArtist(item)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.FullArtist:
go func() {
err := m.commands.RadioGivenArtist(item.SimpleArtist)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.SimpleTrack:
go func() {
err := m.commands.RadioGivenSong(item, 0)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.FullTrack:
go func() {
err := m.commands.RadioGivenSong(item.SimpleTrack, 0)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.PlaylistTrack:
go func() {
err := m.commands.RadioGivenSong(item.Track.SimpleTrack, 0)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.PlaylistItem:
go func() {
err := m.commands.RadioGivenSong(item.Track.Track.SimpleTrack, 0)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
case spotify.SavedTrack:
go func() {
err := m.commands.RadioGivenSong(item.SimpleTrack, 0)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
return
}
}
func (m *mainModel) GoBack() (tea.Cmd, error) {
page = 1
switch m.mode {
case Main:
return tea.Quit, nil
case Albums, Artists, Tracks, Playlist, Devices, Search, Queue:
m.mode = Main
newItems, err := MainView(m.commands)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
case Album:
m.mode = Albums
newItems, err := AlbumsView(m.commands)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
case Artist:
m.mode = Artists
newItems, err := ArtistsView(m.commands)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
case ArtistAlbum:
m.mode = Artist
newItems, err := ArtistAlbumsView(m.artist.ID, m.commands)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
case SearchArtists, SearchTracks, SearchAlbums, SearchPlaylists:
m.mode = Search
items, result, err := SearchView(m.commands, m.search)
if err != nil {
return nil, err
}
m.searchResults = result
m.list.SetItems(items)
case SearchArtist:
m.mode = SearchArtists
newItems, err := SearchArtistsView(m.commands, m.searchResults.Artists)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
case SearchArtistAlbum:
m.mode = SearchArtist
newItems, err := ArtistAlbumsView(m.artist.ID, m.commands)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
case SearchAlbum:
m.mode = SearchAlbums
newItems, err := SearchAlbumsView(m.commands, m.searchResults.Albums)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
case SearchPlaylist:
m.mode = SearchPlaylists
newItems, err := SearchPlaylistsView(m.commands, m.searchResults.Playlists)
if err != nil {
return nil, err
}
m.list.SetItems(newItems)
default:
page = 0
}
return nil, nil
}
type SpotifyURL struct {
ExternalURLs map[string]string
}
func (m *mainModel) CopyToClipboard() error {
item := m.list.SelectedItem().(mainItem).SpotifyItem
switch converted := item.(type) {
case spotify.SimplePlaylist:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case *spotify.FullPlaylist:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case spotify.SimpleAlbum:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case *spotify.FullAlbum:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case spotify.SimpleArtist:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case *spotify.FullArtist:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case spotify.SimpleTrack:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case spotify.PlaylistTrack:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.Track.ExternalURLs["spotify"])
case spotify.SavedTrack:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
case spotify.FullTrack:
go m.SendMessage("Copying link to "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
return clipboard.WriteAll(converted.ExternalURLs["spotify"])
}
return nil
}
func (m *mainModel) SendMessage(msg string, duration time.Duration) {
showingMessage = true
defer func() {
showingMessage = false
}()
m.list.NewStatusMessage(msg)
time.Sleep(duration)
}
func (m *mainModel) QueueItem() error {
var id spotify.ID
var name string
switch item := m.list.SelectedItem().(mainItem).SpotifyItem.(type) {
case spotify.PlaylistTrack:
name = item.Track.Name
id = item.Track.ID
case spotify.SavedTrack:
name = item.Name
id = item.ID
case spotify.SimpleTrack:
name = item.Name
id = item.ID
case spotify.FullTrack:
name = item.Name
id = item.ID
case *spotify.FullTrack:
name = item.Name
id = item.ID
case *spotify.SimpleTrack:
name = item.Name
id = item.ID
case *spotify.SimplePlaylist:
name = item.Name
id = item.ID
}
go m.SendMessage("Adding "+name+" to queue", 2*time.Second)
go func() {
err := m.commands.QueueSong(id)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
if m.mode == Queue {
go func() {
newItems, err := QueueView(m.commands)
if err != nil {
return
}
m.list.SetItems(newItems)
}()
}
return nil
}
func (m *mainModel) DeleteTrackFromPlaylist() error {
if m.mode != Playlist {
return nil
}
track := m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.PlaylistTrack).Track
go m.SendMessage("Deleteing "+track.Name+" from "+m.playlist.Name, 2*time.Second)
go func() {
err := m.commands.DeleteTracksFromPlaylist([]spotify.ID{track.ID}, m.playlist.ID)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
newItems, err := PlaylistView(m.commands, m.playlist)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
m.list.SetItems(newItems)
}()
return nil
}
func (m *mainModel) SelectItem() error {
switch m.mode {
case Queue:
page = 1
go func() {
err := m.commands.Next(m.list.Index(), true)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
newItems, err := QueueView(m.commands)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
m.list.SetItems(newItems)
m.list.ResetSelected()
}()
case Search:
page = 1
switch item := m.list.SelectedItem().(mainItem).SpotifyItem.(type) {
case *spotify.FullArtistPage:
m.mode = SearchArtists
newItems, err := SearchArtistsView(m.commands, item)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case *spotify.SimpleAlbumPage:
m.mode = SearchAlbums
newItems, err := SearchAlbumsView(m.commands, item)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case *spotify.SimplePlaylistPage:
m.mode = SearchPlaylists
newItems, err := SearchPlaylistsView(m.commands, item)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case *spotify.FullTrackPage:
m.mode = SearchTracks
newItems, err := SearchTracksView(item)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
}
case SearchArtists:
page = 1
m.mode = SearchArtist
m.artist = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleArtist)
newItems, err := ArtistAlbumsView(m.artist.ID, m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case SearchArtist:
page = 1
m.mode = SearchArtistAlbum
m.album = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleAlbum)
newItems, err := AlbumTracksView(m.album.ID, m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case SearchAlbums:
page = 1
m.mode = SearchAlbum
m.album = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleAlbum)
newItems, err := AlbumTracksView(m.album.ID, m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case SearchPlaylists:
page = 1
m.mode = SearchPlaylist
playlist := m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimplePlaylist)
m.playlist = playlist
newItems, err := PlaylistView(m.commands, playlist)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case Main:
page = 1
switch item := m.list.SelectedItem().(mainItem).SpotifyItem.(type) {
case spotify.Queue:
m.mode = Queue
newItems, err := QueueView(m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case *spotify.FullArtistCursorPage:
m.mode = Artists
newItems, err := ArtistsView(m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case *spotify.SavedAlbumPage:
m.mode = Albums
newItems, err := AlbumsView(m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case spotify.SimplePlaylist:
m.mode = Playlist
m.playlist = item
newItems, err := PlaylistView(m.commands, item)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case *spotify.SavedTrackPage:
m.mode = Tracks
newItems, err := SavedTracksView(m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
}
case Albums:
page = 1
m.mode = Album
m.album = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleAlbum)
newItems, err := AlbumTracksView(m.album.ID, m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case Artist:
m.mode = ArtistAlbum
m.album = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleAlbum)
newItems, err := AlbumTracksView(m.album.ID, m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case Artists:
m.mode = Artist
m.artist = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleArtist)
newItems, err := ArtistAlbumsView(m.artist.ID, m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
m.list.ResetSelected()
case Album, ArtistAlbum, SearchArtistAlbum, SearchAlbum:
pos := m.list.Cursor() + (m.list.Paginator.Page * m.list.Paginator.TotalPages)
go func() {
_ = m.commands.PlaySongInPlaylist(&m.album.URI, &pos)
}()
case Playlist, SearchPlaylist:
pos := m.list.Cursor() + (m.list.Paginator.Page * m.list.Paginator.PerPage)
go func() {
_ = m.commands.PlaySongInPlaylist(&m.playlist.URI, &pos)
}()
case Tracks:
go func() {
err := m.commands.PlayLikedSongs(m.list.Cursor() + (m.list.Paginator.Page * m.list.Paginator.PerPage))
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
case SearchTracks:
go func() {
err := m.commands.QueueSong(m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.FullTrack).ID)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
return
}
err = m.commands.Next(1, false)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
return
}
}()
case Devices:
go func() {
err := m.commands.SetDevice(m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.PlayerDevice).ID)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
} else {
m.SendMessage("Setting device to "+m.list.SelectedItem().FilterValue(), 2*time.Second)
}
}()
m.mode = "main"
newItems, err := MainView(m.commands)
if err != nil {
return err
}
m.list.SetItems(newItems)
}
return nil
}
func (m *mainModel) Init() tea.Cmd {
mainUpdates = make(chan *mainModel)
return Tick()
}
type tickMsg time.Time
func Tick() tea.Cmd {
return tea.Tick(time.Second*1, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m *mainModel) TickPlayback() {
playing, _ := m.commands.Client().PlayerCurrentlyPlaying(m.commands.Context)
if playing != nil && playing.Playing && playing.Item != nil {
if currentlyPlaying == nil || currentlyPlaying.Item == nil ||
currentlyPlaying.Item.ID != playing.Item.ID {
playbackContext, _ = m.getContext(playing)
}
currentlyPlaying = playing
}
ticker := time.NewTicker(1 * time.Second)
quit := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
m.commands.Log.Debug("TICKING PLAYBACK")
playing, _ := m.commands.Client().PlayerCurrentlyPlaying(m.commands.Context)
if playing != nil && playing.Playing && playing.Item != nil {
if currentlyPlaying == nil || currentlyPlaying.Item == nil ||
currentlyPlaying.Item.ID != playing.Item.ID {
playbackContext, _ = m.getContext(playing)
}
currentlyPlaying = playing
}
case <-quit:
ticker.Stop()
return
}
}
}()
}
func (m *mainModel) View() string {
if m.input.Focused() {
return DocStyle.Render(m.list.View() + "\n" + m.input.View())
}
return DocStyle.Render(m.list.View() + "\n")
}
func (m *mainModel) Typing(msg tea.KeyMsg) (bool, tea.Cmd) {
if msg.String() == "enter" {
items, result, err := SearchView(m.commands, m.input.Value())
if err != nil {
return false, tea.Quit
}
m.searchResults = result
m.search = m.input.Value()
m.list.SetItems(items)
m.list.ResetSelected()
m.input.SetValue("")
m.input.Blur()
return true, nil
}
if msg.String() == "esc" {
m.input.SetValue("")
m.input.Blur()
return false, nil
}
m.input, _ = m.input.Update(msg)
return false, nil
}
func (m *mainModel) getContext(playing *spotify.CurrentlyPlaying) (string, error) {
context := playing.PlaybackContext
uriSplit := strings.Split(string(context.URI), ":")
if len(uriSplit) < 3 {
return "", fmt.Errorf("NO URI")
}
id := strings.Split(string(context.URI), ":")[2]
switch context.Type {
case "album":
m.commands.Log.Debug("ALBUM CONTEXT")
album, err := m.commands.Client().GetAlbum(m.commands.Context, spotify.ID(id))
if err != nil {
return "", err
}
return album.Name, nil
case "playlist":
m.commands.Log.Debug("PLAYLIST CONTEXT")
playlist, err := m.commands.Client().GetPlaylist(m.commands.Context, spotify.ID(id))
if err != nil {
return "", err
}
return playlist.Name, nil
case "artist":
m.commands.Log.Debug("ARTIST CONTEXT")
artist, err := m.commands.Client().GetArtist(m.commands.Context, spotify.ID(id))
if err != nil {
return "", err
}
return artist.Name, nil
}
return "", nil
}
func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Update list items from LoadMore
select {
case update := <-mainUpdates:
m.list.SetItems(update.list.Items())
default:
}
// Call for more items if needed
if m.list.Paginator.Page == m.list.Paginator.TotalPages-1 && m.list.Cursor() == 0 && !loading {
// if last request was still full request more
if len(m.list.Items())%50 == 0 {
go m.LoadMoreItems()
}
}
// Handle user input
switch msg := msg.(type) {
case tickMsg:
playing := currentlyPlaying
if playing != nil && playing.Playing && playing.Item != nil {
cmd := m.progress.SetPercent(float64(playing.Progress) / float64(playing.Item.Duration))
m.playing = playing
m.playbackContext = playbackContext
if m.mode == Queue && len(m.list.Items()) != 0 {
if m.list.Items()[0].(mainItem).SpotifyItem.(spotify.FullTrack).Name != playing.Item.Name {
go func() {
newItems, err := QueueView(m.commands)
if err != nil {
return
}
m.list.SetItems(newItems)
}()
}
}
return m, tea.Batch(Tick(), cmd)
}
return m, Tick()
case progress.FrameMsg:
progressModel, cmd := m.progress.Update(msg)
m.progress = progressModel.(progress.Model)
if !showingMessage {
m.list.NewStatusMessage(
fmt.Sprintf("Now playing %s by %s - %s %s/%s : %s",
m.playing.Item.Name,
m.playing.Item.Artists[0].Name,
m.progress.View(),
(time.Duration(m.playing.Progress) * time.Millisecond).Round(time.Second),
(time.Duration(m.playing.Item.Duration) * time.Millisecond).Round(time.Second),
m.playbackContext),
)
}
return m, cmd
case tea.KeyMsg:
// quit
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if msg.String() == "c" {
err := m.CopyToClipboard()
if err != nil {
go m.SendMessage(err.Error(), 5*time.Second)
}
}
if msg.String() == ">" {
go func() {
err := m.commands.Seek(true)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
}
if msg.String() == "<" {
go func() {
err := m.commands.Seek(false)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
}
if msg.String() == "+" {
go func() {
err := m.commands.ChangeVolume(10)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
}
if msg.String() == "-" {
go func() {
err := m.commands.ChangeVolume(-10)
if err != nil {
m.SendMessage(err.Error(), 5*time.Second)
}
}()
}
// search input
if m.input.Focused() {
search, cmd := m.Typing(msg)
if search {
m.mode = "search"
}
return m, cmd
}
// start search
if msg.String() == "s" || msg.String() == "/" {
m.input.Focus()
}
// enter device selection
if msg.String() == "d" {
m.mode = Devices
newItems, err := DeviceView(m.commands)
if err != nil {
return m, tea.Quit
}
m.list.SetItems(newItems)
m.list.ResetSelected()
}
// go back
if msg.String() == "backspace" || msg.String() == "esc" || msg.String() == "q" {
msg, err := m.GoBack()
if err != nil {
return m, tea.Quit
}
m.list.ResetSelected()
return m, msg
}
if msg.String() == "ctrl+d" {
err := m.DeleteTrackFromPlaylist()
if err != nil {
return m, tea.Quit
}
}
if msg.String() == "ctrl+@" || msg.String() == "ctrl+p" {
err := m.QueueItem()
if err != nil {
return m, tea.Quit
}
}
// select item
if msg.String() == "enter" || msg.String() == " " || msg.String() == "p" {
err := m.SelectItem()
if err != nil {
return m, tea.Quit
}
}
// start radio
if msg.String() == "ctrl+r" {
m.PlayRadio()
}
// handle mouse
case tea.MouseButton:
if msg == 5 {
m.list.CursorUp()
}
if msg == 6 {
m.list.CursorUp()
}
// window size -1 to handle search bar
case tea.WindowSizeMsg:
h, v := DocStyle.GetFrameSize()
m.list.SetSize(msg.Width-h, msg.Height-v-1)
DocStyle.Width(msg.Width - h)
}
// return
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func InitMain(c *commands.Commander, mode Mode) (tea.Model, error) {
prog := progress.New(progress.WithColorProfile(2), progress.WithoutPercentage())
var err error
lipgloss.SetColorProfile(2)
items := []list.Item{}
switch mode {
case Main:
items, err = MainView(c)
if err != nil {
return nil, err
}
case Devices:
items, err = DeviceView(c)
if err != nil {
return nil, err
}
case Tracks:
items, err = SavedTracksView(c)
if err != nil {
return nil, err
}
}
m := &mainModel{
list: list.New(items, list.NewDefaultDelegate(), 0, 0),
commands: c,
mode: mode,
progress: prog,
}
m.list.Title = "GSPOT"
go m.TickPlayback()
Tick()
m.list.DisableQuitKeybindings()
m.list.SetFilteringEnabled(false)
m.list.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "back")),
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
key.NewBinding(key.WithKeys("ctrl"+"r"), key.WithHelp("ctrl+r", "radio")),
key.NewBinding(key.WithKeys("ctrl"+"p"), key.WithHelp("ctrl+p", "queue")),
key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "select device")),
}
}
m.list.AdditionalFullHelpKeys = func() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
key.NewBinding(key.WithKeys(">"), key.WithHelp(">", "seek forward")),
key.NewBinding(key.WithKeys("<"), key.WithHelp("<", "seek backward")),
key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "volume up")),
key.NewBinding(key.WithKeys("-"), key.WithHelp("-", "volume down")),
key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy link to item")),
key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")),
key.NewBinding(key.WithKeys("ctrl"+"r"), key.WithHelp("ctrl+r", "start radio")),
key.NewBinding(key.WithKeys("ctrl"+"p"), key.WithHelp("ctrl+p", "queue song")),
key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "select device")),
}
}
input := textinput.New()
input.Prompt = "$ "
input.Placeholder = "Search..."
input.CharLimit = 250
input.Width = 50
m.input = input
return m, nil
}

View File

@ -1,20 +0,0 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"git.asdf.cafe/abs3nt/gspot/src/components/commands"
)
// StartTea the entry point for the UI. Initializes the model.
func StartTea(cmd *commands.Commander, mode string) error {
m, err := InitMain(cmd, Mode(mode))
if err != nil {
return err
}
P = tea.NewProgram(m, tea.WithAltScreen())
if _, err := P.Run(); err != nil {
return err
}
return nil
}

View File

@ -1,344 +0,0 @@
package tui
import (
"fmt"
"regexp"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/zmb3/spotify/v2"
"golang.org/x/sync/errgroup"
"git.asdf.cafe/abs3nt/gspot/src/components/commands"
)
const regex = `<.*?>`
func DeviceView(commands *commands.Commander) ([]list.Item, error) {
items := []list.Item{}
devices, err := commands.Client().PlayerDevices(commands.Context)
if err != nil {
return nil, err
}
for _, device := range devices {
items = append(items, mainItem{
Name: device.Name,
Desc: fmt.Sprintf("%s - active: %t", device.ID, device.Active),
SpotifyItem: device,
})
}
return items, nil
}
func QueueView(commands *commands.Commander) ([]list.Item, error) {
items := []list.Item{}
tracks, err := commands.Client().GetQueue(commands.Context)
if err != nil {
return nil, err
}
if tracks.CurrentlyPlaying.Name != "" {
items = append(items, mainItem{
Name: tracks.CurrentlyPlaying.Name,
Artist: tracks.CurrentlyPlaying.Artists[0],
Duration: tracks.CurrentlyPlaying.TimeDuration().Round(time.Second).String(),
ID: tracks.CurrentlyPlaying.ID,
Desc: tracks.CurrentlyPlaying.Artists[0].Name + " - " + tracks.CurrentlyPlaying.TimeDuration().
Round(time.Second).
String(),
SpotifyItem: tracks.CurrentlyPlaying,
})
}
for _, track := range tracks.Items {
items = append(items, mainItem{
Name: track.Name,
Artist: track.Artists[0],
Duration: track.TimeDuration().Round(time.Second).String(),
ID: track.ID,
Desc: track.Artists[0].Name + " - " + track.TimeDuration().Round(time.Second).String(),
SpotifyItem: track,
})
}
return items, nil
}
func PlaylistView(commands *commands.Commander, playlist spotify.SimplePlaylist) ([]list.Item, error) {
items := []list.Item{}
playlistItems, err := commands.Client().GetPlaylistItems(
commands.Context,
playlist.ID,
spotify.Limit(50),
spotify.Offset(0),
)
if err != nil {
return nil, err
}
for _, item := range playlistItems.Items {
items = append(items, mainItem{
Name: item.Track.Track.Name,
Artist: item.Track.Track.Artists[0],
Duration: item.Track.Track.TimeDuration().Round(time.Second).String(),
ID: item.Track.Track.ID,
Desc: item.Track.Track.Artists[0].Name + " - " + item.Track.Track.TimeDuration().
Round(time.Second).
String(),
SpotifyItem: item,
})
}
return items, nil
}
func ArtistsView(commands *commands.Commander) ([]list.Item, error) {
items := []list.Item{}
artists, err := commands.Client().CurrentUsersFollowedArtists(commands.Context, spotify.Limit(50), spotify.Offset(0))
if err != nil {
return nil, err
}
for _, artist := range artists.Artists {
items = append(items, mainItem{
Name: artist.Name,
ID: artist.ID,
Desc: fmt.Sprintf("%d followers", artist.Followers.Count),
SpotifyItem: artist.SimpleArtist,
})
}
return items, nil
}
func SearchArtistsView(
commands *commands.Commander,
artists *spotify.FullArtistPage,
) ([]list.Item, error) {
items := []list.Item{}
for _, artist := range artists.Artists {
items = append(items, mainItem{
Name: artist.Name,
ID: artist.ID,
Desc: fmt.Sprintf("%d followers", artist.Followers.Count),
SpotifyItem: artist.SimpleArtist,
})
}
return items, nil
}
func SearchView(commands *commands.Commander, search string) ([]list.Item, *SearchResults, error) {
items := []list.Item{}
result, err := commands.Search(search, 1)
if err != nil {
return nil, nil, err
}
items = append(
items,
mainItem{Name: "Tracks", Desc: "Search results", SpotifyItem: result.Tracks},
mainItem{Name: "Albums", Desc: "Search results", SpotifyItem: result.Albums},
mainItem{Name: "Artists", Desc: "Search results", SpotifyItem: result.Artists},
mainItem{Name: "Playlists", Desc: "Search results", SpotifyItem: result.Playlists},
)
results := &SearchResults{
Tracks: result.Tracks,
Playlists: result.Playlists,
Albums: result.Albums,
Artists: result.Artists,
}
return items, results, nil
}
func AlbumsView(commands *commands.Commander) ([]list.Item, error) {
items := []list.Item{}
albums, err := commands.Client().CurrentUsersAlbums(commands.Context, spotify.Limit(50), spotify.Offset((page-1)*50))
if err != nil {
return nil, err
}
for _, album := range albums.Albums {
items = append(items, mainItem{
Name: album.Name,
ID: album.ID,
Desc: fmt.Sprintf(
"%s by %s, %d tracks, released %d",
album.AlbumType,
album.Artists[0].Name,
album.Tracks.Total,
album.ReleaseDateTime().Year(),
),
SpotifyItem: album.SimpleAlbum,
})
}
return items, nil
}
func SearchPlaylistsView(commands *commands.Commander, playlists *spotify.SimplePlaylistPage) ([]list.Item, error) {
items := []list.Item{}
for _, playlist := range playlists.Playlists {
items = append(items, mainItem{
Name: playlist.Name,
Desc: stripHTMLRegex(playlist.Description),
SpotifyItem: playlist,
})
}
return items, nil
}
func SearchAlbumsView(commands *commands.Commander, albums *spotify.SimpleAlbumPage) ([]list.Item, error) {
items := []list.Item{}
for _, album := range albums.Albums {
items = append(items, mainItem{
Name: album.Name,
ID: album.ID,
Desc: fmt.Sprintf(
"%s by %s, released %d",
album.AlbumType,
album.Artists[0].Name,
album.ReleaseDateTime().Year(),
),
SpotifyItem: album,
})
}
return items, nil
}
func ArtistAlbumsView(album spotify.ID, commands *commands.Commander) ([]list.Item, error) {
items := []list.Item{}
albums, err := commands.ArtistAlbums(album, 1)
if err != nil {
return nil, err
}
for _, album := range albums.Albums {
items = append(items, mainItem{
Name: album.Name,
ID: album.ID,
Desc: fmt.Sprintf("%s by %s", album.AlbumType, album.Artists[0].Name),
SpotifyItem: album,
})
}
return items, err
}
func AlbumTracksView(album spotify.ID, commands *commands.Commander) ([]list.Item, error) {
items := []list.Item{}
tracks, err := commands.AlbumTracks(album, 1)
if err != nil {
return nil, err
}
for _, track := range tracks.Tracks {
items = append(items, mainItem{
Name: track.Name,
Artist: track.Artists[0],
Duration: track.TimeDuration().Round(time.Second).String(),
ID: track.ID,
SpotifyItem: track,
Desc: track.Artists[0].Name + " - " + track.TimeDuration().Round(time.Second).String(),
})
}
return items, err
}
func SearchTracksView(tracks *spotify.FullTrackPage) ([]list.Item, error) {
items := []list.Item{}
for _, track := range tracks.Tracks {
items = append(items, mainItem{
Name: track.Name,
Artist: track.Artists[0],
Duration: track.TimeDuration().Round(time.Second).String(),
ID: track.ID,
SpotifyItem: track,
Desc: track.Artists[0].Name + " - " + track.TimeDuration().Round(time.Second).String(),
})
}
return items, nil
}
func SavedTracksView(commands *commands.Commander) ([]list.Item, error) {
items := []list.Item{}
tracks, err := commands.Client().CurrentUsersTracks(commands.Context, spotify.Limit(50), spotify.Offset((page-1)*50))
if err != nil {
return nil, err
}
for _, track := range tracks.Tracks {
items = append(items, mainItem{
Name: track.Name,
Artist: track.Artists[0],
Duration: track.TimeDuration().Round(time.Second).String(),
ID: track.ID,
SpotifyItem: track,
Desc: track.Artists[0].Name + " - " + track.TimeDuration().Round(time.Second).String(),
})
}
return items, err
}
func MainView(c *commands.Commander) ([]list.Item, error) {
c.Log.Debug("SWITCHING TO MAIN VIEW")
wg := errgroup.Group{}
var savedItems *spotify.SavedTrackPage
var playlists *spotify.SimplePlaylistPage
var artists *spotify.FullArtistCursorPage
var albums *spotify.SavedAlbumPage
wg.Go(func() (err error) {
savedItems, err = c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset(0))
return
})
wg.Go(func() (err error) {
playlists, err = c.Client().CurrentUsersPlaylists(c.Context, spotify.Limit(50), spotify.Offset(0))
return
})
wg.Go(func() (err error) {
artists, err = c.Client().CurrentUsersFollowedArtists(c.Context, spotify.Limit(50), spotify.Offset(0))
return
})
wg.Go(func() (err error) {
albums, err = c.Client().CurrentUsersAlbums(c.Context, spotify.Limit(50), spotify.Offset(0))
return
})
err := wg.Wait()
if err != nil {
return nil, err
}
items := []list.Item{}
if savedItems != nil && savedItems.Total != 0 {
items = append(items, mainItem{
Name: "Saved Tracks",
Desc: fmt.Sprintf("%d saved songs", savedItems.Total),
SpotifyItem: savedItems,
})
}
if albums != nil && albums.Total != 0 {
items = append(items, mainItem{
Name: "Albums",
Desc: fmt.Sprintf("%d albums", albums.Total),
SpotifyItem: albums,
})
}
if artists != nil && artists.Total != 0 {
items = append(items, mainItem{
Name: "Artists",
Desc: fmt.Sprintf("%d artists", artists.Total),
SpotifyItem: artists,
})
}
items = append(items, mainItem{
Name: "Queue",
Desc: "Your Current Queue",
SpotifyItem: spotify.Queue{},
})
if playlists != nil && playlists.Total != 0 {
for _, playlist := range playlists.Playlists {
items = append(items, mainItem{
Name: playlist.Name,
Desc: stripHTMLRegex(playlist.Description),
SpotifyItem: playlist,
})
}
}
return items, nil
}
func stripHTMLRegex(s string) string {
r := regexp.MustCompile(regex)
return r.ReplaceAllString(s, "")
}

View File

@ -1,102 +0,0 @@
package tuitview
import (
"sync/atomic"
"git.asdf.cafe/abs3nt/gspot/src/components/commands"
"github.com/rivo/tview"
"github.com/zmb3/spotify/v2"
)
var (
tracksLoading = atomic.Bool{}
playlistsLoading = atomic.Bool{}
tracksPage = 1
playlistsPage = 1
)
func TuitView(cmd *commands.Commander) error {
tracksLoading.Store(false)
playlistsLoading.Store(false)
playlistsList := tview.NewList().ShowSecondaryText(false)
playlistsList.SetBorder(true).SetTitle("Playlists")
savedTracksList := tview.NewList().ShowSecondaryText(false)
savedTracksList.SetWrapAround(false)
savedTracksList.SetBorder(true).SetTitle("Tracks")
savedTracksList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
go cmd.PlayLikedSongs(index)
})
flex := tview.NewFlex().AddItem(playlistsList, 0, 1, false).AddItem(savedTracksList, 0, 2, true)
playlists, err := cmd.Playlists(1)
if err != nil {
return err
}
for _, playlist := range playlists.Playlists {
playlistsList.AddItem(playlist.Name, "", 0, func() {
playlistTracksList := tview.NewList().ShowSecondaryText(false)
playlistTracksList.SetBorder(true).SetTitle(playlist.Name)
playlistTracksList.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
go cmd.PlaySongInPlaylist((*spotify.URI)(&secondaryText), &index)
})
tracks, err := cmd.PlaylistTracks(playlist.ID, 1)
if err != nil {
return
}
for _, track := range tracks.Items {
playlistTracksList.AddItem(track.Track.Track.Name+" - "+track.Track.Track.Artists[0].Name, string(playlist.URI), 0, nil)
}
flex.Clear()
flex.AddItem(playlistsList, 0, 1, false)
flex.AddItem(playlistTracksList, 0, 2, false)
})
}
tracks, err := cmd.TrackList(1)
if err != nil {
return err
}
for _, track := range tracks.Tracks {
savedTracksList.AddItem(track.Name+" - "+track.Artists[0].Name, "", 0, nil)
}
playlistsList.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
if playlistsList.GetItemCount()%50 != 0 {
return
}
if playlistsList.GetItemCount()-index < 40 {
go func() {
if playlistsLoading.Load() {
return
}
playlistsLoading.Store(true)
defer playlistsLoading.Store(false)
playlistsPage++
newPlaylists, _ := cmd.Playlists(playlistsPage)
for _, playlist := range newPlaylists.Playlists {
savedTracksList.AddItem(playlist.Name, "", 0, nil)
}
}()
}
})
savedTracksList.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
if savedTracksList.GetItemCount()%50 != 0 {
return
}
if savedTracksList.GetItemCount()-index < 40 {
go func() {
if tracksLoading.Load() {
return
}
tracksLoading.Store(true)
defer tracksLoading.Store(false)
tracksPage++
tracks, _ := cmd.TrackList(tracksPage)
for _, track := range tracks.Tracks {
savedTracksList.AddItem(track.Name+" - "+track.Artists[0].Name, "", 0, nil)
}
}()
}
})
if err := tview.NewApplication().EnableMouse(true).SetRoot(flex, true).Run(); err != nil {
return err
}
return nil
}

View File

@ -1,115 +0,0 @@
package youtube
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/user"
"path/filepath"
"golang.org/x/net/context"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
func getClient(ctx context.Context, config *oauth2.Config) *http.Client {
cacheFile, err := tokenCacheFile()
if err != nil {
log.Fatalf("Unable to get path to cached credential file. %v", err)
}
tok, err := tokenFromFile(cacheFile)
if err != nil {
tok = getTokenFromWeb(config)
saveToken(cacheFile, tok)
}
return config.Client(ctx, tok)
}
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
fmt.Printf("Go to the following link in your browser then type the "+
"authorization code: \n%v\n", authURL)
var code string
if _, err := fmt.Scan(&code); err != nil {
log.Fatalf("Unable to read authorization code %v", err)
}
tok, err := config.Exchange(context.Background(), code)
if err != nil {
log.Fatalf("Unable to retrieve token from web %v", err)
}
return tok
}
func tokenCacheFile() (string, error) {
usr, err := user.Current()
if err != nil {
return "", err
}
tokenCacheDir := filepath.Join(usr.HomeDir, ".credentials")
err = os.MkdirAll(tokenCacheDir, 0o700)
if err != nil {
return "", err
}
return filepath.Join(tokenCacheDir,
url.QueryEscape("youtube-go-quickstart.json")), err
}
func tokenFromFile(file string) (*oauth2.Token, error) {
f, err := os.Open(file)
handleError(err, "Error opening file")
t := &oauth2.Token{}
err = json.NewDecoder(f).Decode(t)
defer f.Close()
return t, err
}
func saveToken(file string, token *oauth2.Token) {
fmt.Printf("Saving credential file to: %s\n", file)
f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
log.Fatalf("Unable to cache oauth token: %v", err)
}
defer f.Close()
err = json.NewEncoder(f).Encode(token)
handleError(err, "Error encoding token")
}
func handleError(err error, message string) {
if message == "" {
message = "Error making API call"
}
if err != nil {
log.Fatalf(message+": %v", err.Error())
}
}
func Search(query string) string {
ctx := context.Background()
confDir, _ := os.UserConfigDir()
b, err := os.ReadFile(filepath.Join(confDir, "gspot", "client_secret.json"))
if err != nil {
log.Fatalf("Unable to read client secret file: %v", err)
}
config, err := google.ConfigFromJSON(b, youtube.YoutubeReadonlyScope)
if err != nil {
log.Fatalf("Unable to parse client secret file to config: %v", err)
}
client := getClient(ctx, config)
service, err := youtube.NewService(ctx, option.WithHTTPClient(client))
handleError(err, "Error creating YouTube client")
call := service.Search.List([]string{"snippet"})
call.Q(query)
response, err := call.Do()
handleError(err, "")
return fmt.Sprintf("https://www.youtube.com/watch?v=%s", response.Items[0].Id.VideoId)
}

View File

@ -1,10 +1,8 @@
package config
type Config struct {
ClientID string `yaml:"client_id"`
ClientId string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
ClientSecretCmd string `yaml:"client_secret_cmd"`
Port string `yaml:"port"`
LogLevel string `yaml:"log_level" default:"info"`
LogOutput string `yaml:"log_output" default:"stdout"`
}

View File

@ -13,12 +13,18 @@ import (
"github.com/zmb3/spotify/v2"
spotifyauth "github.com/zmb3/spotify/v2/auth"
"go.uber.org/fx"
"golang.org/x/exp/slog"
"golang.org/x/oauth2"
"git.asdf.cafe/abs3nt/gspot/src/config"
"git.asdf.cafe/abs3nt/gospt-ng/src/config"
)
type SpotifyClientResult struct {
fx.Out
Client *spotify.Client
}
var (
auth *spotifyauth.Authenticator
ch = make(chan *spotify.Client)
@ -32,25 +38,28 @@ func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error)
return fn(req)
}
func GetClient(conf *config.Config) (c *spotify.Client, err error) {
if conf.ClientID == "" || (conf.ClientSecret == "" && conf.ClientSecretCmd == "") || conf.Port == "" {
return nil, fmt.Errorf("INVALID CONFIG")
func NewSpotifyClient(conf *config.Config) (c SpotifyClientResult, err error) {
if conf.ClientId == "" || (conf.ClientSecret == "" && conf.ClientSecretCmd == "") || conf.Port == "" {
fmt.Println("PLEASE WRITE YOUR CONFIG FILE IN", filepath.Join(configDir, "gospt/gospt.yml"))
fmt.Println("GO HERE TO AND MAKE AN APPLICATION: https://developer.spotify.com/dashboard/applications")
fmt.Print("\nclient_id: \"idgoesherelikethis\"\nclient_secret: \"secretgoesherelikethis\"\nport:\"8888\"\n\n")
return SpotifyClientResult{}, fmt.Errorf("\nINVALID CONFIG")
}
if conf.ClientSecretCmd != "" {
args := strings.Fields(conf.ClientSecretCmd)
cmd := args[0]
secretCommand := exec.Command(cmd)
secret_command := exec.Command(cmd)
if len(args) > 1 {
secretCommand.Args = args
secret_command.Args = args
}
secret, err := secretCommand.Output()
secret, err := secret_command.Output()
if err != nil {
panic(err)
}
conf.ClientSecret = strings.TrimSpace(string(secret))
}
auth = spotifyauth.New(
spotifyauth.WithClientID(conf.ClientID),
spotifyauth.WithClientID(conf.ClientId),
spotifyauth.WithClientSecret(conf.ClientSecret),
spotifyauth.WithRedirectURL(fmt.Sprintf("http://localhost:%s/callback", conf.Port)),
spotifyauth.WithScopes(
@ -73,17 +82,17 @@ func GetClient(conf *config.Config) (c *spotify.Client, err error) {
spotifyauth.ScopeStreaming,
),
)
if _, err := os.Stat(filepath.Join(configDir, "gspot/auth.json")); err == nil {
authFilePath := filepath.Join(configDir, "gspot/auth.json")
if _, err := os.Stat(filepath.Join(configDir, "gospt/auth.json")); err == nil {
authFilePath := filepath.Join(configDir, "gospt/auth.json")
authFile, err := os.Open(authFilePath)
if err != nil {
return nil, err
return SpotifyClientResult{}, err
}
defer authFile.Close()
tok := &oauth2.Token{}
err = json.NewDecoder(authFile).Decode(tok)
if err != nil {
return nil, err
return SpotifyClientResult{}, err
}
authCtx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
@ -92,20 +101,20 @@ func GetClient(conf *config.Config) (c *spotify.Client, err error) {
}),
})
authClient := auth.Client(authCtx, tok)
client := spotify.New(authClient, spotify.WithRetry(true))
newToken, err := client.Token()
client := spotify.New(authClient)
new_token, err := client.Token()
if err != nil {
return nil, err
return SpotifyClientResult{}, err
}
out, err := json.MarshalIndent(newToken, "", " ")
out, err := json.MarshalIndent(new_token, "", " ")
if err != nil {
return nil, err
return SpotifyClientResult{}, err
}
err = os.WriteFile(authFilePath, out, 0o600)
if err != nil {
return nil, fmt.Errorf("failed to save auth")
return SpotifyClientResult{}, fmt.Errorf("failed to save auth")
}
return client, nil
return SpotifyClientResult{Client: client}, nil
}
// first start an HTTP server
http.HandleFunc("/callback", completeAuth)
@ -120,7 +129,7 @@ func GetClient(conf *config.Config) (c *spotify.Client, err error) {
_ = server.ListenAndServe()
}()
url := auth.AuthURL(state)
slog.Info("AUTH", "url", url)
fmt.Println(url)
cmd := exec.Command("xdg-open", url)
_ = cmd.Start()
// wait for auth to complete
@ -130,10 +139,10 @@ func GetClient(conf *config.Config) (c *spotify.Client, err error) {
// use the client to make calls that require authorization
user, err := client.CurrentUser(context.Background())
if err != nil {
return nil, err
return SpotifyClientResult{}, err
}
slog.Info("AUTH", "You are logged in as:", user.ID)
return client, nil
fmt.Println("You are logged in as:", user.ID)
return SpotifyClientResult{Client: client}, nil
}
func completeAuth(w http.ResponseWriter, r *http.Request) {
@ -151,12 +160,12 @@ func completeAuth(w http.ResponseWriter, r *http.Request) {
slog.Error("AUTHENTICATOR", "failed to unmarshal", err)
os.Exit(1)
}
err = os.WriteFile(filepath.Join(configDir, "gspot/auth.json"), out, 0o600)
err = os.WriteFile(filepath.Join(configDir, "gospt/auth.json"), out, 0o600)
if err != nil {
slog.Error("AUTHENTICATOR", "failed to save auth", err)
}
// use the token to get an authenticated client
client := spotify.New(auth.Client(r.Context(), tok), spotify.WithRetry(true))
client := spotify.New(auth.Client(r.Context(), tok))
fmt.Fprintf(w, "Login Completed!")
ch <- client
}

View File

@ -1,18 +0,0 @@
package services
import (
"git.asdf.cafe/abs3nt/gunner"
"go.uber.org/fx"
"git.asdf.cafe/abs3nt/gspot/src/config"
)
var Config = fx.Options(
fx.Provide(
func() *config.Config {
c := &config.Config{}
gunner.LoadApp(c, "gspot")
return c
},
),
)