Compare commits
85 Commits
Author | SHA1 | Date | |
---|---|---|---|
e4f23c6805 | |||
2d5567621f | |||
40d0d2c919 | |||
eb03bcd385 | |||
5c423b082f | |||
958d214505 | |||
779cb517c9 | |||
1b52748db0 | |||
57c97cddb4 | |||
e05de59160 | |||
c5e536a472 | |||
2c3a2e1778 | |||
8ce5611154 | |||
4d3f1d85bf | |||
5e1dae88e0 | |||
cceb5fb8bb | |||
864c3163b0 | |||
2a70db8607 | |||
c1b8eee83d | |||
4549a21b9a | |||
007dcd39e3 | |||
a156f078aa | |||
58f09618e9 | |||
49880d6831 | |||
c3b56d8e7e | |||
efc8ebb943 | |||
54e6ec39d3 | |||
5e2327771a | |||
2760c3ae93 | |||
8347c24fbe | |||
a90f7d2ce9 | |||
4d4038fd99 | |||
5e40c8e2a2 | |||
2a28c74caf | |||
c1ac26e684 | |||
8a21c7cb52 | |||
a7152075fa | |||
c28448c295 | |||
ec69919ec6 | |||
dd2284770d | |||
52dbe52c50 | |||
bdec590110 | |||
b516cb8d2f | |||
bef9898bf2 | |||
786ee38671 | |||
823774f7bd | |||
ba07377ca8 | |||
dc2c74dd4b | |||
c26d11299a | |||
c1d4a0b5e0 | |||
338bef9f75 | |||
bafaa8a941 | |||
e6344bef01 | |||
ae4291032e | |||
ad77e043d6 | |||
2116052d5e | |||
1faea3fd7f | |||
f6f7d3bd70 | |||
86c2c38dfe | |||
59fff8dfef | |||
8e067ac28d | |||
c474690f3e | |||
d955ddaffb | |||
06f1974cd6 | |||
affb5fbaba | |||
567fb54ac2 | |||
ad37b573cd | |||
3b8a51db39 | |||
fa06934f31 | |||
1b7090084f | |||
a4b6f7bc40 | |||
122c330c44 | |||
775331ce69 | |||
aaa467eee0 | |||
e56ffc60a3 | |||
1747097e7a | |||
e88a964b3a | |||
ba2b6f9952 | |||
162574aa62 | |||
8fccb0886f | |||
7ecb57a8fd | |||
cc9b6481aa | |||
e1b1a0056a | |||
0b5e21a6f5 | |||
80164f126a |
15
.gitea/workflows/push.yaml
Normal file
15
.gitea/workflows/push.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
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
|
28
.gitea/workflows/releaser.yaml
Normal file
28
.gitea/workflows/releaser.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
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
3
.gitignore
vendored
@ -1 +1,2 @@
|
||||
gospt-ng
|
||||
dist
|
||||
gspot
|
||||
|
82
.golangci.yml
Normal file
82
.golangci.yml
Normal file
@ -0,0 +1,82 @@
|
||||
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)"
|
||||
|
@ -18,7 +18,7 @@ builds:
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
ldflags:
|
||||
- -s -w -X git.asdf.cafe/abs3nt/gospt-ng/src.components.cli.Version={{.Version}}
|
||||
- -s -w -X git.asdf.cafe/abs3nt/gspot/src/components/cli.Version={{.Version}}
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
@ -32,10 +32,8 @@ archives:
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
files:
|
||||
- completions/*
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
name_template: "checksums.txt"
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
@ -44,16 +42,16 @@ changelog:
|
||||
- title: Added
|
||||
regexp: '^.*?ADD(\([[:word:]]+\))??!?:.+$'
|
||||
order: 0
|
||||
- title: 'Bug fixes'
|
||||
- title: "Bug fixes"
|
||||
regexp: '^.*?BUG(\([[:word:]]+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: 'Enhancements'
|
||||
- title: "Enhancements"
|
||||
regexp: '^.*?IMPROVED(\([[:word:]]+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: 'Docs'
|
||||
- title: "Docs"
|
||||
regexp: '^.*?DOC(\([[:word:]]+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: 'CI'
|
||||
- title: "CI"
|
||||
regexp: '^.*?CI(\([[:word:]]+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: Others
|
||||
|
@ -1,14 +0,0 @@
|
||||
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
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
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.
|
25
Makefile
25
Makefile
@ -1,24 +1,21 @@
|
||||
build: gospt-ng
|
||||
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/ .
|
||||
|
||||
gospt-ng: $(shell find . -name '*.go')
|
||||
go build -o gospt-ng .
|
||||
|
||||
run:
|
||||
go run main.go
|
||||
run: build
|
||||
./dist/gspot
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -f gospt-ng
|
||||
rm -rf completions
|
||||
rm -rf dist
|
||||
|
||||
uninstall:
|
||||
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
|
||||
rm -f /usr/bin/gspot
|
||||
rm -f /usr/share/zsh/site-functions/_gspot
|
||||
rm -f /usr/share/bash-completion/completions/gspot
|
||||
|
||||
install:
|
||||
cp gospt-ng /usr/bin
|
||||
cp ./completions/zsh_autocomplete /usr/share/zsh/site-functions/_gospt-ng
|
||||
cp ./dist/gspot /usr/bin
|
||||
cp ./completions/_gspot /usr/share/zsh/site-functions/_gspot
|
||||
cp ./completions/gspot /usr/share/bash-completion/completionsgspotg
|
||||
|
82
README.md
Normal file
82
README.md
Normal file
@ -0,0 +1,82 @@
|
||||
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)
|
16
completions/_gspot
Executable file
16
completions/_gspot
Executable file
@ -0,0 +1,16 @@
|
||||
#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
|
35
completions/gspot
Executable file
35
completions/gspot
Executable file
@ -0,0 +1,35 @@
|
||||
#! /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
|
@ -1,20 +0,0 @@
|
||||
#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
96
go.mod
@ -1,34 +1,82 @@
|
||||
module git.asdf.cafe/abs3nt/gospt-ng
|
||||
module git.asdf.cafe/abs3nt/gspot
|
||||
|
||||
go 1.22.0
|
||||
go 1.22.3
|
||||
|
||||
require (
|
||||
gfx.cafe/util/go/fxplus v0.0.0-20231226111635-bc00a6a250fb
|
||||
git.asdf.cafe/abs3nt/gunner v0.0.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
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/cristalhq/aconfig v0.18.5 // indirect
|
||||
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/cristalhq/aconfig/aconfigdotenv v0.17.1 // 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
|
||||
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
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
|
251
go.sum
251
go.sum
@ -13,12 +13,18 @@ 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=
|
||||
@ -31,41 +37,69 @@ 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/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
|
||||
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
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/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.5 h1:QqXH/Gy2c4QUQJTV2BN8UAuL/rqZ3IwhvxeC8OgzquA=
|
||||
github.com/cristalhq/aconfig v0.18.5/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/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=
|
||||
@ -86,8 +120,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=
|
||||
@ -98,9 +132,11 @@ 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=
|
||||
@ -110,72 +146,127 @@ 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/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/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/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/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/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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.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.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/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/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.1 h1:2ENzO3XQLOQBuxgT1Z9+PlCBSkjNgzFzmRaPns0tjM4=
|
||||
github.com/zmb3/spotify/v2 v2.4.1/go.mod h1:p3r7mCCxHepzVaJOe3w1dlx9SL+T8iiQR14tfXJpuTE=
|
||||
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=
|
||||
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.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=
|
||||
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=
|
||||
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.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
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/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=
|
||||
@ -186,8 +277,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-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE=
|
||||
golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
|
||||
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/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=
|
||||
@ -210,6 +301,8 @@ 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=
|
||||
@ -236,19 +329,23 @@ 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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
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/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=
|
||||
@ -259,6 +356,8 @@ 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=
|
||||
@ -284,19 +383,27 @@ 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.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.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/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.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
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/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=
|
||||
@ -305,7 +412,9 @@ 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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
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/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=
|
||||
@ -351,6 +460,8 @@ 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=
|
||||
@ -371,13 +482,14 @@ 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=
|
||||
@ -408,6 +520,11 @@ 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=
|
||||
@ -420,6 +537,9 @@ 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=
|
||||
@ -432,8 +552,9 @@ 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.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
||||
google.golang.org/protobuf v1.27.1/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=
|
||||
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=
|
||||
@ -449,6 +570,32 @@ 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=
|
||||
|
56
main.go
56
main.go
@ -1,20 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"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"
|
||||
"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"
|
||||
)
|
||||
|
||||
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),
|
||||
app.Config,
|
||||
services.Config,
|
||||
fx.Provide(
|
||||
Context,
|
||||
cache.NewCache,
|
||||
commands.NewCommander,
|
||||
logger.NewLogger,
|
||||
),
|
||||
fx.Invoke(
|
||||
cli.Run,
|
||||
@ -22,3 +38,33 @@ 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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
3
renovate.json
Normal file
3
renovate.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["config:recommended"]
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
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,
|
||||
)
|
117
src/components/cache/cache.go
vendored
Normal file
117
src/components/cache/cache.go
vendored
Normal file
@ -0,0 +1,117 @@
|
||||
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)
|
||||
}
|
@ -1,114 +1,451 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/zmb3/spotify/v2"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.asdf.cafe/abs3nt/gospt-ng/src/components/commands"
|
||||
"git.asdf.cafe/abs3nt/gspot/src/components/commands"
|
||||
"git.asdf.cafe/abs3nt/gspot/src/components/tui"
|
||||
"git.asdf.cafe/abs3nt/gspot/src/components/tuitview"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func Run(c *commands.Commander, s fx.Shutdowner) {
|
||||
defer func() {
|
||||
err := s.Shutdown()
|
||||
if err != nil {
|
||||
slog.Error("SHUTDOWN", "error shutting down", err)
|
||||
}
|
||||
}()
|
||||
app := &cli.App{
|
||||
EnableBashCompletion: true,
|
||||
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")
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "play",
|
||||
Aliases: []string{"pl", "start", "s"},
|
||||
Usage: "Plays spotify",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
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.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(cCtx *cli.Context) error {
|
||||
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.Pause()
|
||||
},
|
||||
Category: "Playback",
|
||||
},
|
||||
{
|
||||
Name: "toggleplay",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Toggles play/pause",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
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.TogglePlay()
|
||||
},
|
||||
Category: "Playback",
|
||||
},
|
||||
{
|
||||
Name: "link",
|
||||
Aliases: []string{"yy"},
|
||||
Usage: "Prints the current song's spotify link",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
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.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()
|
||||
},
|
||||
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",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
return c.Next()
|
||||
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(cCtx *cli.Context) error {
|
||||
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.Previous()
|
||||
},
|
||||
Category: "Playback",
|
||||
},
|
||||
{
|
||||
Name: "like",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Likes the current song",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
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.Like()
|
||||
},
|
||||
Category: "Library Management",
|
||||
},
|
||||
{
|
||||
Name: "unlike",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Unlikes the current song",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
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.UnLike()
|
||||
},
|
||||
Category: "Library Management",
|
||||
},
|
||||
{
|
||||
Name: "nowplaying",
|
||||
Aliases: []string{"now"},
|
||||
Usage: "Prints the current song",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
return c.NowPlaying()
|
||||
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()
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "download_cover",
|
||||
Usage: "Downloads the cover of the current song",
|
||||
Aliases: []string{"dl"},
|
||||
Args: true,
|
||||
ArgsUsage: "download_cover <path>",
|
||||
Action: func(cCtx *cli.Context) error {
|
||||
return c.DownloadCover(cCtx.Args().First())
|
||||
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)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
slog.Error("COMMANDER", "run error", err)
|
||||
os.Exit(1)
|
||||
if err := app.Run(c.Context, os.Args); err != nil {
|
||||
c.Log.Error("COMMANDER", "run error", err)
|
||||
s.Shutdown(fx.ExitCode(1))
|
||||
}
|
||||
s.Shutdown()
|
||||
}
|
||||
|
@ -1,21 +1,19 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/zmb3/spotify/v2"
|
||||
)
|
||||
|
||||
func (c *Commander) activateDevice(ctx context.Context) (spotify.ID, error) {
|
||||
func (c *Commander) activateDevice() (spotify.ID, error) {
|
||||
var device *spotify.PlayerDevice
|
||||
configDir, _ := os.UserConfigDir()
|
||||
if _, err := os.Stat(filepath.Join(configDir, "gospt/device.json")); err == nil {
|
||||
deviceFile, err := os.Open(filepath.Join(configDir, "gospt/device.json"))
|
||||
if _, err := os.Stat(filepath.Join(configDir, "gspot/device.json")); err == nil {
|
||||
deviceFile, err := os.Open(filepath.Join(configDir, "gspot/device.json"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -28,12 +26,12 @@ func (c *Commander) activateDevice(ctx context.Context) (spotify.ID, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = c.Client.TransferPlayback(ctx, device.ID, true)
|
||||
err = c.Client().TransferPlayback(c.Context, device.ID, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
slog.Error("COMMANDER", "failed to activated device", "YOU MUST RUN gospt setdevice FIRST")
|
||||
c.Log.Error("COMMANDER", "failed to activated device", "YOU MUST RUN gspot setdevice FIRST")
|
||||
}
|
||||
return device.ID, nil
|
||||
}
|
||||
|
18
src/components/commands/album.go
Normal file
18
src/components/commands/album.go
Normal file
@ -0,0 +1,18 @@
|
||||
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))
|
||||
}
|
12
src/components/commands/artist.go
Normal file
12
src/components/commands/artist.go
Normal file
@ -0,0 +1,12 @@
|
||||
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
|
||||
}
|
@ -2,9 +2,15 @@ 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 {
|
||||
@ -17,20 +23,53 @@ type CommanderParams struct {
|
||||
fx.In
|
||||
|
||||
Context context.Context
|
||||
Client *spotify.Client
|
||||
Log *slog.Logger
|
||||
Cache *cache.Cache
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
type Commander struct {
|
||||
Context context.Context
|
||||
Client *spotify.Client
|
||||
User *spotify.PrivateUser
|
||||
Log *slog.Logger
|
||||
Cache *cache.Cache
|
||||
mu sync.RWMutex
|
||||
cl *spotify.Client
|
||||
conf *config.Config
|
||||
}
|
||||
|
||||
func NewCommander(p CommanderParams) CommanderResult {
|
||||
c := &Commander{
|
||||
Context: p.Context,
|
||||
Client: p.Client,
|
||||
Log: p.Log,
|
||||
Cache: p.Cache,
|
||||
conf: p.Config,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
53
src/components/commands/devices.go
Normal file
53
src/components/commands/devices.go
Normal file
@ -0,0 +1,53 @@
|
||||
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")
|
||||
}
|
@ -6,8 +6,11 @@ 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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -3,10 +3,19 @@ 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
|
||||
}
|
||||
|
@ -1,19 +1,31 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zmb3/spotify/v2"
|
||||
)
|
||||
|
||||
func (c *Commander) Next() error {
|
||||
err := c.Client.Next(c.Context)
|
||||
if err != nil {
|
||||
if isNoActiveError(err) {
|
||||
deviceId, err := c.activateDevice(c.Context)
|
||||
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
|
||||
}
|
||||
err = c.Client.NextOpt(c.Context, &spotify.PlayOptions{
|
||||
DeviceID: &deviceId,
|
||||
}
|
||||
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
|
||||
@ -23,3 +35,83 @@ func (c *Commander) Next() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// found := false
|
||||
// playingIndex := 0
|
||||
current, err := c.Client().PlayerCurrentlyPlaying(c.Context)
|
||||
if err != nil {
|
||||
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: ¤t.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: ¤t.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
|
||||
}
|
||||
|
@ -2,17 +2,34 @@ package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/zmb3/spotify/v2"
|
||||
)
|
||||
|
||||
func (c *Commander) NowPlaying() error {
|
||||
current, err := c.Client.PlayerCurrentlyPlaying(c.Context)
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(song)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
package commands
|
||||
|
||||
func (c *Commander) Pause() error {
|
||||
return c.Client.Pause(c.Context)
|
||||
return c.Client().Pause(c.Context)
|
||||
}
|
||||
|
@ -1,16 +1,22 @@
|
||||
package commands
|
||||
|
||||
import "github.com/zmb3/spotify/v2"
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"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(c.Context)
|
||||
deviceID, err := c.activateDevice()
|
||||
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 {
|
||||
@ -22,3 +28,150 @@ 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
|
||||
}
|
||||
|
23
src/components/commands/playlist.go
Normal file
23
src/components/commands/playlist.go
Normal file
@ -0,0 +1,23 @@
|
||||
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))
|
||||
}
|
@ -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(c.Context)
|
||||
deviceID, err := c.activateDevice()
|
||||
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
|
||||
|
25
src/components/commands/queue.go
Normal file
25
src/components/commands/queue.go
Normal file
@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
631
src/components/commands/radio.go
Normal file
631
src/components/commands/radio.go
Normal file
@ -0,0 +1,631 @@
|
||||
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
|
||||
}
|
19
src/components/commands/repeat.go
Normal file
19
src/components/commands/repeat.go
Normal file
@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
12
src/components/commands/search.go
Normal file
12
src/components/commands/search.go
Normal file
@ -0,0 +1,12 @@
|
||||
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
|
||||
}
|
25
src/components/commands/seek.go
Normal file
25
src/components/commands/seek.go
Normal file
@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
14
src/components/commands/shuffle.go
Normal file
14
src/components/commands/shuffle.go
Normal file
@ -0,0 +1,14 @@
|
||||
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
|
||||
}
|
38
src/components/commands/status.go
Normal file
38
src/components/commands/status.go
Normal file
@ -0,0 +1,38 @@
|
||||
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
|
||||
}
|
@ -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 err
|
||||
return c.Play()
|
||||
}
|
||||
if state.Playing {
|
||||
return c.Client.Pause(c.Context)
|
||||
return c.Pause()
|
||||
}
|
||||
return c.Client.Play(c.Context)
|
||||
return c.Play()
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
35
src/components/commands/volume.go
Normal file
35
src/components/commands/volume.go
Normal file
@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
17
src/components/commands/youtube-link.go
Normal file
17
src/components/commands/youtube-link.go
Normal file
@ -0,0 +1,17 @@
|
||||
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
|
||||
}
|
67
src/components/logger/logger.go
Normal file
67
src/components/logger/logger.go
Normal file
@ -0,0 +1,67 @@
|
||||
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]",
|
||||
})),
|
||||
}
|
||||
}
|
165
src/components/tui/loader.go
Normal file
165
src/components/tui/loader.go
Normal file
@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
}
|
937
src/components/tui/main.go
Normal file
937
src/components/tui/main.go
Normal file
@ -0,0 +1,937 @@
|
||||
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
|
||||
}
|
20
src/components/tui/tui.go
Normal file
20
src/components/tui/tui.go
Normal file
@ -0,0 +1,20 @@
|
||||
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
|
||||
}
|
344
src/components/tui/views.go
Normal file
344
src/components/tui/views.go
Normal file
@ -0,0 +1,344 @@
|
||||
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, "")
|
||||
}
|
102
src/components/tuitview/tuitview.go
Normal file
102
src/components/tuitview/tuitview.go
Normal file
@ -0,0 +1,102 @@
|
||||
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
|
||||
}
|
115
src/components/youtube/youtube.go
Normal file
115
src/components/youtube/youtube.go
Normal file
@ -0,0 +1,115 @@
|
||||
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)
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
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"`
|
||||
}
|
||||
|
@ -13,18 +13,12 @@ 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/gospt-ng/src/config"
|
||||
"git.asdf.cafe/abs3nt/gspot/src/config"
|
||||
)
|
||||
|
||||
type SpotifyClientResult struct {
|
||||
fx.Out
|
||||
Client *spotify.Client
|
||||
}
|
||||
|
||||
var (
|
||||
auth *spotifyauth.Authenticator
|
||||
ch = make(chan *spotify.Client)
|
||||
@ -38,28 +32,25 @@ func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
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")
|
||||
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")
|
||||
}
|
||||
if conf.ClientSecretCmd != "" {
|
||||
args := strings.Fields(conf.ClientSecretCmd)
|
||||
cmd := args[0]
|
||||
secret_command := exec.Command(cmd)
|
||||
secretCommand := exec.Command(cmd)
|
||||
if len(args) > 1 {
|
||||
secret_command.Args = args
|
||||
secretCommand.Args = args
|
||||
}
|
||||
secret, err := secret_command.Output()
|
||||
secret, err := secretCommand.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(
|
||||
@ -82,17 +73,17 @@ func NewSpotifyClient(conf *config.Config) (c SpotifyClientResult, err error) {
|
||||
spotifyauth.ScopeStreaming,
|
||||
),
|
||||
)
|
||||
if _, err := os.Stat(filepath.Join(configDir, "gospt/auth.json")); err == nil {
|
||||
authFilePath := filepath.Join(configDir, "gospt/auth.json")
|
||||
if _, err := os.Stat(filepath.Join(configDir, "gspot/auth.json")); err == nil {
|
||||
authFilePath := filepath.Join(configDir, "gspot/auth.json")
|
||||
authFile, err := os.Open(authFilePath)
|
||||
if err != nil {
|
||||
return SpotifyClientResult{}, err
|
||||
return nil, err
|
||||
}
|
||||
defer authFile.Close()
|
||||
tok := &oauth2.Token{}
|
||||
err = json.NewDecoder(authFile).Decode(tok)
|
||||
if err != nil {
|
||||
return SpotifyClientResult{}, err
|
||||
return nil, err
|
||||
}
|
||||
authCtx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
@ -101,20 +92,20 @@ func NewSpotifyClient(conf *config.Config) (c SpotifyClientResult, err error) {
|
||||
}),
|
||||
})
|
||||
authClient := auth.Client(authCtx, tok)
|
||||
client := spotify.New(authClient)
|
||||
new_token, err := client.Token()
|
||||
client := spotify.New(authClient, spotify.WithRetry(true))
|
||||
newToken, err := client.Token()
|
||||
if err != nil {
|
||||
return SpotifyClientResult{}, err
|
||||
return nil, err
|
||||
}
|
||||
out, err := json.MarshalIndent(new_token, "", " ")
|
||||
out, err := json.MarshalIndent(newToken, "", " ")
|
||||
if err != nil {
|
||||
return SpotifyClientResult{}, err
|
||||
return nil, err
|
||||
}
|
||||
err = os.WriteFile(authFilePath, out, 0o600)
|
||||
if err != nil {
|
||||
return SpotifyClientResult{}, fmt.Errorf("failed to save auth")
|
||||
return nil, fmt.Errorf("failed to save auth")
|
||||
}
|
||||
return SpotifyClientResult{Client: client}, nil
|
||||
return client, nil
|
||||
}
|
||||
// first start an HTTP server
|
||||
http.HandleFunc("/callback", completeAuth)
|
||||
@ -129,7 +120,7 @@ func NewSpotifyClient(conf *config.Config) (c SpotifyClientResult, err error) {
|
||||
_ = server.ListenAndServe()
|
||||
}()
|
||||
url := auth.AuthURL(state)
|
||||
fmt.Println(url)
|
||||
slog.Info("AUTH", "url", url)
|
||||
cmd := exec.Command("xdg-open", url)
|
||||
_ = cmd.Start()
|
||||
// wait for auth to complete
|
||||
@ -139,10 +130,10 @@ func NewSpotifyClient(conf *config.Config) (c SpotifyClientResult, err error) {
|
||||
// use the client to make calls that require authorization
|
||||
user, err := client.CurrentUser(context.Background())
|
||||
if err != nil {
|
||||
return SpotifyClientResult{}, err
|
||||
return nil, err
|
||||
}
|
||||
fmt.Println("You are logged in as:", user.ID)
|
||||
return SpotifyClientResult{Client: client}, nil
|
||||
slog.Info("AUTH", "You are logged in as:", user.ID)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func completeAuth(w http.ResponseWriter, r *http.Request) {
|
||||
@ -160,12 +151,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, "gospt/auth.json"), out, 0o600)
|
||||
err = os.WriteFile(filepath.Join(configDir, "gspot/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))
|
||||
client := spotify.New(auth.Client(r.Context(), tok), spotify.WithRetry(true))
|
||||
fmt.Fprintf(w, "Login Completed!")
|
||||
ch <- client
|
||||
}
|
||||
|
18
src/services/config.go
Normal file
18
src/services/config.go
Normal file
@ -0,0 +1,18 @@
|
||||
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
|
||||
},
|
||||
),
|
||||
)
|
Loading…
Reference in New Issue
Block a user