Compare commits

...

86 Commits

Author SHA1 Message Date
e4f23c6805
Deps up
All checks were successful
builder / build (push) Successful in 49s
2024-10-26 14:30:49 -07:00
2d5567621f
rm
All checks were successful
builder / build (push) Successful in 39s
2024-08-27 22:37:59 -07:00
40d0d2c919
deps up
All checks were successful
builder / build (push) Successful in 45s
2024-08-27 09:27:41 -07:00
eb03bcd385 fix(deps): update module modernc.org/sqlite to v1.32.0 (#24)
All checks were successful
builder / build (push) Successful in 47s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | require | minor | `v1.30.2` -> `v1.32.0` |

---

### Release Notes

<details>
<summary>cznic/sqlite (modernc.org/sqlite)</summary>

### [`v1.32.0`](https://gitlab.com/cznic/sqlite/compare/v1.31.1...v1.32.0)

[Compare Source](https://gitlab.com/cznic/sqlite/compare/v1.31.1...v1.32.0)

### [`v1.31.1`](https://gitlab.com/cznic/sqlite/compare/v1.31.0...v1.31.1)

[Compare Source](https://gitlab.com/cznic/sqlite/compare/v1.31.0...v1.31.1)

### [`v1.31.0`](https://gitlab.com/cznic/sqlite/compare/v1.30.2...v1.31.0)

[Compare Source](https://gitlab.com/cznic/sqlite/compare/v1.30.2...v1.31.0)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MzguMCIsInVwZGF0ZWRJblZlciI6IjM4LjIxLjEiLCJ0YXJnZXRCcmFuY2giOiJtYXN0ZXIiLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #24
2024-08-27 16:24:47 +00:00
5c423b082f fix(deps): update github.com/rivo/tview digest to fd649db (#26)
Some checks failed
builder / build (push) Has been cancelled
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [github.com/rivo/tview](https://github.com/rivo/tview) | require | digest | `b0a7293` -> `fd649db` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC44LjMiLCJ1cGRhdGVkSW5WZXIiOiIzOC4zOS40IiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #26
2024-08-27 16:24:33 +00:00
958d214505 fix(deps): update golang.org/x/exp digest to 9b4947d (#31)
Some checks failed
builder / build (push) Has been cancelled
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| golang.org/x/exp | require | digest | `8a7402a` -> `9b4947d` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC4yMS41IiwidXBkYXRlZEluVmVyIjoiMzguNTEuMCIsInRhcmdldEJyYW5jaCI6Im1hc3RlciIsImxhYmVscyI6W119-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #31
2024-08-27 16:24:06 +00:00
779cb517c9 fix(deps): update module github.com/charmbracelet/bubbles to v0.19.0 (#33)
Some checks failed
builder / build (push) Has been cancelled
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles) | require | minor | `v0.18.0` -> `v0.19.0` |

---

### Release Notes

<details>
<summary>charmbracelet/bubbles (github.com/charmbracelet/bubbles)</summary>

### [`v0.19.0`](https://github.com/charmbracelet/bubbles/releases/tag/v0.19.0)

[Compare Source](https://github.com/charmbracelet/bubbles/compare/v0.18.0...v0.19.0)

### Bugs? Squashed (along with a few nice lil’ features).

Community-Driven Development?! Yep, the majority of the changes in this release were done by the community. *Thank you* all for your contributions that made this release possible.

#### Progress: custom chars

You can now customize the filled and empty characters of the progress bar.

```go
p := progress.New(progress.WithFillCharacters('>', '.'))
```

![progress bar example](https://github.com/user-attachments/assets/ee1a1351-ebee-4f39-8543-af464e60b099)

#### Table improvements

##### Help is on the way

Table now includes a short and full help view so it's easier than ever to tell your users how to interact with the table.

```go
// Render a table with its help.
t := table.New()
view := t.View() + "\n" + t.HelpView()
```

<img src="https://github.com/user-attachments/assets/22195043-5578-4201-982d-a8f9b7eefc5f" width="600">

##### Accessing columns

You can also now get the table's columns (this already existed for rows).

```go
package table

// Columns returns the current columns.
func (m Model) Columns() []Column
```

#### List: page navigation is fixed!

Previously, `list.NextPage()` and `list.PrevPage()` didn't work because the methods did not have pointer receivers. We've fixed this…by making them pointer receivers!

⚠️ Note that this is a minor API change and you *might* need to update your app to pass a pointer receiver to your model rather than a copy. Details in [#&#8203;458](https://github.com/charmbracelet/bubbles/issues/458).

```go
package progress

// NextPage moves to the next page, if available.
func (m *Model) NextPage()

// PrevPage moves to the previous page, if available.
func (m *Model) PrevPage()
```

***

#### What’s Changed

##### Changed

-   Textarea: Improve setting width by [@&#8203;mikelorant](https://github.com/mikelorant) in https://github.com/charmbracelet/bubbles/pull/496
-   Textinput: fix out of range panic if no matched suggestions by [@&#8203;rdnt](https://github.com/rdnt) in https://github.com/charmbracelet/bubbles/pull/473
-   List: Fix no-op list pagination functions by [@&#8203;nekopy](https://github.com/nekopy) in https://github.com/charmbracelet/bubbles/pull/458
-   Table: Clarify position constant in JoinHorizontal by [@&#8203;aditipatelpro](https://github.com/aditipatelpro) in https://github.com/charmbracelet/bubbles/pull/577
-   Progress: make full/empty fill characters configurable by [@&#8203;rwinkhart](https://github.com/rwinkhart) in https://github.com/charmbracelet/bubbles/pull/409
-   Dependencies: switch to x/ansi for text manipulation by [@&#8203;aymanbagabas](https://github.com/aymanbagabas) in https://github.com/charmbracelet/bubbles/pull/505

##### Added

-   Textarea: add help to textarea key bindings by [@&#8203;TravisYeah](https://github.com/TravisYeah) in https://github.com/charmbracelet/bubbles/pull/418
-   Textarea: Add multiline placeholder by [@&#8203;mikelorant](https://github.com/mikelorant) in https://github.com/charmbracelet/bubbles/pull/302
-   Table: Add column return function by [@&#8203;abeleinin](https://github.com/abeleinin) in https://github.com/charmbracelet/bubbles/pull/369
-   Table: Implement help.Keymap interface and add quit mapping by [@&#8203;prgres](https://github.com/prgres) in https://github.com/charmbracelet/bubbles/pull/440
-   Ctrl+Left/Right for WordForward/Backward by [@&#8203;maaslalani](https://github.com/maaslalani) in https://github.com/charmbracelet/bubbles/pull/387
-   Use goreleaser for releases by [@&#8203;aymanbagabas](https://github.com/aymanbagabas) in https://github.com/charmbracelet/bubbles/pull/526

##### Fixed

-   Table: Render Row Tests by [@&#8203;maaslalani](https://github.com/maaslalani) in https://github.com/charmbracelet/bubbles/pull/487
-   Table: Only render columns with a positive width by [@&#8203;fabio42](https://github.com/fabio42) in https://github.com/charmbracelet/bubbles/pull/465
-   Table: Fix inheritence of SelectedStyle in StyleFunc by [@&#8203;gabrielfu](https://github.com/gabrielfu) in https://github.com/charmbracelet/bubbles/pull/539
-   Table: Don't include header height in the total table size by [@&#8203;prgres](https://github.com/prgres) in https://github.com/charmbracelet/bubbles/pull/434
-   Table: Fix premature viewport scroll by [@&#8203;dzeleniak](https://github.com/dzeleniak) in https://github.com/charmbracelet/bubbles/pull/429
-   Textarea: Fix end of buffer character by [@&#8203;mikelorant](https://github.com/mikelorant) in https://github.com/charmbracelet/bubbles/pull/491
-   Textarea: Set textarea default EndOfBufferCharacter to ' ' by [@&#8203;blvrd](https://github.com/blvrd) in https://github.com/charmbracelet/bubbles/pull/510
-   Textarea: End of Buffer alignment by [@&#8203;maaslalani](https://github.com/maaslalani) in https://github.com/charmbracelet/bubbles/pull/486
-   Textinput: don't block input on validation by [@&#8203;GabrielNagy](https://github.com/GabrielNagy) in https://github.com/charmbracelet/bubbles/pull/185
-   Viewport: Fix division by zero in scrollpercentage by [@&#8203;zMoooooritz](https://github.com/zMoooooritz) in https://github.com/charmbracelet/bubbles/pull/494
-   Help: Fix centering by [@&#8203;gabe565](https://github.com/gabe565) in https://github.com/charmbracelet/bubbles/pull/516
-   Progress: Stop spring defaults from overriding WithStringOptions by [@&#8203;nervo](https://github.com/nervo) in https://github.com/charmbracelet/bubbles/pull/540
-   Cursor: Make SetMode method in cursor library handle invalid mode values correctly by [@&#8203;anirudhaCodes](https://github.com/anirudhaCodes) in https://github.com/charmbracelet/bubbles/pull/477

##### Test coverage 

-   Add tests for textarea view by [@&#8203;mikelorant](https://github.com/mikelorant) in https://github.com/charmbracelet/bubbles/pull/485
-   Add tests for paginator by [@&#8203;anirudhaCodes](https://github.com/anirudhaCodes) in https://github.com/charmbracelet/bubbles/pull/480
-   Add tests for textInput Tests by [@&#8203;KevM](https://github.com/KevM) in https://github.com/charmbracelet/bubbles/pull/500
-   Improve textarea tests by [@&#8203;mikelorant](https://github.com/mikelorant) in https://github.com/charmbracelet/bubbles/pull/490

#### New Contributors

-   [@&#8203;rdnt](https://github.com/rdnt) made their first contribution in https://github.com/charmbracelet/bubbles/pull/473
-   [@&#8203;rwinkhart](https://github.com/rwinkhart) made their first contribution in https://github.com/charmbracelet/bubbles/pull/409
-   [@&#8203;mikelorant](https://github.com/mikelorant) made their first contribution in https://github.com/charmbracelet/bubbles/pull/485
-   [@&#8203;anirudhaCodes](https://github.com/anirudhaCodes) made their first contribution in https://github.com/charmbracelet/bubbles/pull/480
-   [@&#8203;nekopy](https://github.com/nekopy) made their first contribution in https://github.com/charmbracelet/bubbles/pull/458
-   [@&#8203;TravisYeah](https://github.com/TravisYeah) made their first contribution in https://github.com/charmbracelet/bubbles/pull/418
-   [@&#8203;abeleinin](https://github.com/abeleinin) made their first contribution in https://github.com/charmbracelet/bubbles/pull/369
-   [@&#8203;fabio42](https://github.com/fabio42) made their first contribution in https://github.com/charmbracelet/bubbles/pull/465
-   [@&#8203;prgres](https://github.com/prgres) made their first contribution in https://github.com/charmbracelet/bubbles/pull/440
-   [@&#8203;zMoooooritz](https://github.com/zMoooooritz) made their first contribution in https://github.com/charmbracelet/bubbles/pull/494
-   [@&#8203;dzeleniak](https://github.com/dzeleniak) made their first contribution in https://github.com/charmbracelet/bubbles/pull/429
-   [@&#8203;KevM](https://github.com/KevM) made their first contribution in https://github.com/charmbracelet/bubbles/pull/500
-   [@&#8203;gabe565](https://github.com/gabe565) made their first contribution in https://github.com/charmbracelet/bubbles/pull/516
-   [@&#8203;blvrd](https://github.com/blvrd) made their first contribution in https://github.com/charmbracelet/bubbles/pull/510
-   [@&#8203;nervo](https://github.com/nervo) made their first contribution in https://github.com/charmbracelet/bubbles/pull/540
-   [@&#8203;gabrielfu](https://github.com/gabrielfu) made their first contribution in https://github.com/charmbracelet/bubbles/pull/539
-   [@&#8203;aditipatelpro](https://github.com/aditipatelpro) made their first contribution in https://github.com/charmbracelet/bubbles/pull/577

**Full Changelog**: https://github.com/charmbracelet/bubbles/compare/v0.18.0...v0.19.0

***

<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg?1" width="400"></a>

Thoughts? Questions? We love hearing from you. Feel free to reach out on [Twitter](https://twitter.com/charmcli), [The Fediverse](https://mastodon.social/@&#8203;charm), or on [Discord](https://charm.sh/chat).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC40NC4zIiwidXBkYXRlZEluVmVyIjoiMzguNDQuMyIsInRhcmdldEJyYW5jaCI6Im1hc3RlciIsImxhYmVscyI6W119-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #33
2024-08-27 16:23:52 +00:00
1b52748db0
refill
All checks were successful
builder / build (push) Successful in 37s
2024-08-22 00:35:50 -07:00
57c97cddb4
Refill
All checks were successful
builder / build (push) Successful in 47s
2024-08-22 00:17:10 -07:00
e05de59160 fix(deps): update golang.org/x/exp digest to 8a7402a (#23)
All checks were successful
builder / build (push) Successful in 38s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| golang.org/x/exp | require | digest | `e3f2596` -> `8a7402a` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MzQuMiIsInVwZGF0ZWRJblZlciI6IjM3LjQzNC4yIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #23
2024-07-20 17:37:50 +00:00
c5e536a472 fix(deps): update golang.org/x/exp digest to e3f2596 (#22)
All checks were successful
builder / build (push) Successful in 38s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| golang.org/x/exp | require | digest | `46b0784` -> `e3f2596` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MzIuMCIsInVwZGF0ZWRJblZlciI6IjM3LjQzMi4wIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #22
2024-07-16 20:43:14 +00:00
2c3a2e1778 fix(deps): update module modernc.org/sqlite to v1.30.2 (#21)
All checks were successful
builder / build (push) Successful in 43s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | require | patch | `v1.30.1` -> `v1.30.2` |

---

### Release Notes

<details>
<summary>cznic/sqlite (modernc.org/sqlite)</summary>

### [`v1.30.2`](https://gitlab.com/cznic/sqlite/compare/v1.30.1...v1.30.2)

[Compare Source](https://gitlab.com/cznic/sqlite/compare/v1.30.1...v1.30.2)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjkuMSIsInVwZGF0ZWRJblZlciI6IjM3LjQyOS4xIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #21
2024-07-14 19:35:29 +00:00
8ce5611154 fix(deps): update module github.com/charmbracelet/lipgloss to v0.12.1 (#20)
Some checks failed
builder / build (push) Has been cancelled
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [github.com/charmbracelet/lipgloss](https://github.com/charmbracelet/lipgloss) | require | minor | `v0.11.1` -> `v0.12.1` |

---

### Release Notes

<details>
<summary>charmbracelet/lipgloss (github.com/charmbracelet/lipgloss)</summary>

### [`v0.12.1`](https://github.com/charmbracelet/lipgloss/releases/tag/v0.12.1)

[Compare Source](https://github.com/charmbracelet/lipgloss/compare/v0.12.0...v0.12.1)

This release fixes a regression with regard to border calculations introduced in Lip Gloss v0.11.1.

***

<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>

Thoughts? Questions? We love hearing from you. Feel free to reach out on [Twitter](https://twitter.com/charmcli), [The Fediverse](https://mastodon.technology/@&#8203;charm), or on [Discord](https://charm.sh/chat).

### [`v0.12.0`](https://github.com/charmbracelet/lipgloss/releases/tag/v0.12.0)

[Compare Source](https://github.com/charmbracelet/lipgloss/compare/v0.11.1...v0.12.0)

### Lists, Check ✓

This release adds a new sub-package for rendering trees and lists.

```go
import "github.com/charmbracelet/lipgloss/list"
```

Define a new list.

```go
l := list.New("A", "B", "C")
```

Print the list.

```go
fmt.Println(l)

// • A
// • B
// • C
```

Lists have the ability to nest.

```go
l := list.New(
  "A", list.New("Artichoke"),
  "B", list.New("Baking Flour", "Bananas", "Barley", "Bean Sprouts"),
  "C", list.New("Cashew Apple", "Cashews", "Coconut Milk", "Curry Paste", "Currywurst"),
  "D", list.New("Dill", "Dragonfruit", "Dried Shrimp"),
  "E", list.New("Eggs"),
  "F", list.New("Fish Cake", "Furikake"),
  "J", list.New("Jicama"),
  "K", list.New("Kohlrabi"),
  "L", list.New("Leeks", "Lentils", "Licorice Root"),
)
```

Print the list.

```go
fmt.Println(l)
```

<p align="center">
<img width="600" alt="image" src="https://github.com/charmbracelet/lipgloss/assets/42545625/0dc9f440-0748-4151-a3b0-7dcf29dfcdb0">
</p>

Lists can be customized via their enumeration function as well as using
`lipgloss.Style`s.

```go
enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).MarginRight(1)
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).MarginRight(1)

l := list.New(
  "Glossier",
  "Claire’s Boutique",
  "Nyx",
  "Mac",
  "Milk",
).
  Enumerator(list.Roman).
  EnumeratorStyle(enumeratorStyle).
  ItemStyle(itemStyle)
```

Print the list.

<p align="center">
<img width="600" alt="List example" src="https://github.com/charmbracelet/lipgloss/assets/42545625/360494f1-57fb-4e13-bc19-0006efe01561">
</p>

In addition to the predefined enumerators (`Arabic`, `Alphabet`, `Roman`, `Bullet`, `Tree`),
you may also define your own custom enumerator:

```go
l := list.New("Duck", "Duck", "Duck", "Duck", "Goose", "Duck", "Duck")

func DuckDuckGooseEnumerator(l list.Items, i int) string {
    if l.At(i).Value() == "Goose" {
        return "Honk →"
    }
    return ""
}

l = l.Enumerator(DuckDuckGooseEnumerator)
```

Print the list:

<p align="center">
<img width="600" alt="image" src="https://github.com/charmbracelet/lipgloss/assets/42545625/157aaf30-140d-4948-9bb4-dfba46e5b87e">
</p>

If you need, you can also build lists incrementally:

```go
l := list.New()

for i := 0; i < repeat; i++ {
    l.Item("Lip Gloss")
}
```

***

<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>

Thoughts? Questions? We love hearing from you. Feel free to reach out on [Twitter](https://twitter.com/charmcli), [The Fediverse](https://mastodon.technology/@&#8203;charm), or on [Discord](https://charm.sh/chat).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjguMiIsInVwZGF0ZWRJblZlciI6IjM3LjQyOS4xIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #20
2024-07-14 19:35:20 +00:00
4d3f1d85bf fix(deps): update module github.com/lmittmann/tint to v1.0.5 (#19)
All checks were successful
builder / build (push) Successful in 37s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [github.com/lmittmann/tint](https://github.com/lmittmann/tint) | require | patch | `v1.0.4` -> `v1.0.5` |

---

### Release Notes

<details>
<summary>lmittmann/tint (github.com/lmittmann/tint)</summary>

### [`v1.0.5`](https://github.com/lmittmann/tint/releases/tag/v1.0.5)

[Compare Source](https://github.com/lmittmann/tint/compare/v1.0.4...v1.0.5)

#### What's Changed

-   Preserve `tint.Err` attribute key by [@&#8203;kwargs](https://github.com/kwargs) in https://github.com/lmittmann/tint/pull/66

#### New Contributors

-   [@&#8203;kwargs](https://github.com/kwargs) made their first contribution in https://github.com/lmittmann/tint/pull/66

**Full Changelog**: https://github.com/lmittmann/tint/compare/v1.0.4...v1.0.5

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjcuMCIsInVwZGF0ZWRJblZlciI6IjM3LjQyNy4wIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #19
2024-07-11 18:25:12 +00:00
5e1dae88e0 fix(deps): update module github.com/charmbracelet/lipgloss to v0.11.1 (#18)
All checks were successful
builder / build (push) Successful in 38s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [github.com/charmbracelet/lipgloss](https://github.com/charmbracelet/lipgloss) | require | patch | `v0.11.0` -> `v0.11.1` |

---

### Release Notes

<details>
<summary>charmbracelet/lipgloss (github.com/charmbracelet/lipgloss)</summary>

### [`v0.11.1`](https://github.com/charmbracelet/lipgloss/releases/tag/v0.11.1)

[Compare Source](https://github.com/charmbracelet/lipgloss/compare/v0.11.0...v0.11.1)

A small patch release to fix text truncation in table cells https://github.com/charmbracelet/lipgloss/issues/324.

#### What's Changed

-   chore: remove deprecated Copy() calls by [@&#8203;meowgorithm](https://github.com/meowgorithm) in https://github.com/charmbracelet/lipgloss/pull/306
-   feat: deprecate Style.ColorWhitespace by [@&#8203;meowgorithm](https://github.com/meowgorithm) in https://github.com/charmbracelet/lipgloss/pull/311
-   feat: deprecate Style.ColorWhitespace by [@&#8203;meowgorithm](https://github.com/meowgorithm) in https://github.com/charmbracelet/lipgloss/pull/314
-   fix: Deprecate UnsetBorderTopBackgroundColor in favor of UnsetBorderTopBackground by [@&#8203;nervo](https://github.com/nervo) in https://github.com/charmbracelet/lipgloss/pull/315

**Full Changelog**: https://github.com/charmbracelet/lipgloss/compare/v0.11.0...v0.11.1

***

<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>

Thoughts? Questions? We love hearing from you. Feel free to reach out on [Twitter](https://twitter.com/charmcli), [The Fediverse](https://mastodon.technology/@&#8203;charm), or [Discord](https://charm.sh/discord).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjcuMCIsInVwZGF0ZWRJblZlciI6IjM3LjQyNy4wIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #18
2024-07-10 19:50:23 +00:00
cceb5fb8bb fix(deps): update module google.golang.org/api to v0.188.0 (#17)
All checks were successful
builder / build (push) Successful in 37s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [google.golang.org/api](https://github.com/googleapis/google-api-go-client) | require | minor | `v0.187.0` -> `v0.188.0` |

---

### Release Notes

<details>
<summary>googleapis/google-api-go-client (google.golang.org/api)</summary>

### [`v0.188.0`](https://github.com/googleapis/google-api-go-client/releases/tag/v0.188.0)

[Compare Source](https://github.com/googleapis/google-api-go-client/compare/v0.187.0...v0.188.0)

##### Features

-   **all:** Auto-regenerate discovery clients ([#&#8203;2665](https://github.com/googleapis/google-api-go-client/issues/2665)) ([e84fa65](e84fa6508e))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2669](https://github.com/googleapis/google-api-go-client/issues/2669)) ([6df3749](6df3749296))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2671](https://github.com/googleapis/google-api-go-client/issues/2671)) ([0d54a85](0d54a85400))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2673](https://github.com/googleapis/google-api-go-client/issues/2673)) ([88240e3](88240e3d98))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2674](https://github.com/googleapis/google-api-go-client/issues/2674)) ([d465cec](d465cec68d))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2675](https://github.com/googleapis/google-api-go-client/issues/2675)) ([a9177bd](a9177bdbdb))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2677](https://github.com/googleapis/google-api-go-client/issues/2677)) ([5dd2fb2](5dd2fb2378))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2678](https://github.com/googleapis/google-api-go-client/issues/2678)) ([d17f6be](d17f6beb5a))

##### Bug Fixes

-   Allow ForceSendFields to work for map types ([#&#8203;2670](https://github.com/googleapis/google-api-go-client/issues/2670)) ([40b5113](40b5113127))
-   Check \[]bytes > 0 instead of nil ([#&#8203;2667](https://github.com/googleapis/google-api-go-client/issues/2667)) ([711eb91](711eb913fe)), refs [#&#8203;2647](https://github.com/googleapis/google-api-go-client/issues/2647)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjYuMiIsInVwZGF0ZWRJblZlciI6IjM3LjQyNi4yIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #17
2024-07-10 00:42:45 +00:00
864c3163b0 fix(deps): update golang.org/x/exp digest to 46b0784 (#16)
All checks were successful
builder / build (push) Successful in 37s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| golang.org/x/exp | require | digest | `7f521ea` -> `46b0784` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjUuMSIsInVwZGF0ZWRJblZlciI6IjM3LjQyNS4xIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #16
2024-07-08 01:48:11 +00:00
2a70db8607
remove imports
All checks were successful
builder / build (push) Successful in 43s
2024-07-06 14:05:01 -07:00
c1b8eee83d fix(deps): update module google.golang.org/api to v0.187.0 (#9)
Some checks failed
builder / build (push) Failing after 18s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [google.golang.org/api](https://github.com/googleapis/google-api-go-client) | require | minor | `v0.182.0` -> `v0.187.0` |

---

### Release Notes

<details>
<summary>googleapis/google-api-go-client (google.golang.org/api)</summary>

### [`v0.187.0`](https://github.com/googleapis/google-api-go-client/releases/tag/v0.187.0)

[Compare Source](https://github.com/googleapis/google-api-go-client/compare/v0.186.0...v0.187.0)

##### Features

-   **all:** Auto-regenerate discovery clients ([#&#8203;2655](https://github.com/googleapis/google-api-go-client/issues/2655)) ([1a28e06](1a28e0622f))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2658](https://github.com/googleapis/google-api-go-client/issues/2658)) ([719f988](719f988502))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2659](https://github.com/googleapis/google-api-go-client/issues/2659)) ([7cd88da](7cd88dabf7))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2660](https://github.com/googleapis/google-api-go-client/issues/2660)) ([3ca2f84](3ca2f844a9))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2661](https://github.com/googleapis/google-api-go-client/issues/2661)) ([0a238f5](0a238f578c))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2663](https://github.com/googleapis/google-api-go-client/issues/2663)) ([6e061ce](6e061ced5f))

##### Bug Fixes

-   **gensupport:** Wrap chunk upload err for retries ([#&#8203;2657](https://github.com/googleapis/google-api-go-client/issues/2657)) ([a758bc1](a758bc17ee))
-   Pass through gRPC api key option to new auth lib ([#&#8203;2664](https://github.com/googleapis/google-api-go-client/issues/2664)) ([e051997](e051997022))

### [`v0.186.0`](https://github.com/googleapis/google-api-go-client/releases/tag/v0.186.0)

[Compare Source](https://github.com/googleapis/google-api-go-client/compare/v0.185.0...v0.186.0)

##### Features

-   **all:** Auto-regenerate discovery clients ([#&#8203;2641](https://github.com/googleapis/google-api-go-client/issues/2641)) ([72fb128](72fb1281b3))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2644](https://github.com/googleapis/google-api-go-client/issues/2644)) ([20ffdd8](20ffdd8000))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2645](https://github.com/googleapis/google-api-go-client/issues/2645)) ([c1a7681](c1a768193e))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2648](https://github.com/googleapis/google-api-go-client/issues/2648)) ([1bac79d](1bac79d78d))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2649](https://github.com/googleapis/google-api-go-client/issues/2649)) ([695484b](695484ba67))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2652](https://github.com/googleapis/google-api-go-client/issues/2652)) ([10c47f3](10c47f3750))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2653](https://github.com/googleapis/google-api-go-client/issues/2653)) ([bc370a7](bc370a705c))

### [`v0.185.0`](https://github.com/googleapis/google-api-go-client/releases/tag/v0.185.0)

[Compare Source](https://github.com/googleapis/google-api-go-client/compare/v0.184.0...v0.185.0)

##### Features

-   **all:** Auto-regenerate discovery clients ([#&#8203;2636](https://github.com/googleapis/google-api-go-client/issues/2636)) ([51ff8a4](51ff8a4794))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2638](https://github.com/googleapis/google-api-go-client/issues/2638)) ([0c868b2](0c868b2608))

##### Bug Fixes

-   **internal/gensupport:** Update shouldRetry for GCS uploads ([#&#8203;2634](https://github.com/googleapis/google-api-go-client/issues/2634)) ([ea513cb](ea513cb749))

### [`v0.184.0`](https://github.com/googleapis/google-api-go-client/releases/tag/v0.184.0)

[Compare Source](https://github.com/googleapis/google-api-go-client/compare/v0.183.0...v0.184.0)

##### Features

-   **all:** Auto-regenerate discovery clients ([#&#8203;2624](https://github.com/googleapis/google-api-go-client/issues/2624)) ([7fccba6](7fccba6a6f))
-   Regen cloudcommerceprocurement v1 from updated discovery file ([#&#8203;2627](https://github.com/googleapis/google-api-go-client/issues/2627)) ([7e30ed2](7e30ed210c))
-   Support structpb.Struct as req/resp ([#&#8203;2632](https://github.com/googleapis/google-api-go-client/issues/2632)) ([ebc44d1](ebc44d1595)), refs [#&#8203;2601](https://github.com/googleapis/google-api-go-client/issues/2601)

##### Bug Fixes

-   **cba:** Update credsNewAuth to support oauth2 over mTLS ([#&#8203;2610](https://github.com/googleapis/google-api-go-client/issues/2610)) ([953f728](953f728941))

### [`v0.183.0`](https://github.com/googleapis/google-api-go-client/releases/tag/v0.183.0)

[Compare Source](https://github.com/googleapis/google-api-go-client/compare/v0.182.0...v0.183.0)

##### Features

-   **all:** Auto-regenerate discovery clients ([#&#8203;2611](https://github.com/googleapis/google-api-go-client/issues/2611)) ([1de148b](1de148b049))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2616](https://github.com/googleapis/google-api-go-client/issues/2616)) ([5f21214](5f21214e22))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2617](https://github.com/googleapis/google-api-go-client/issues/2617)) ([08fdd71](08fdd71cae))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2619](https://github.com/googleapis/google-api-go-client/issues/2619)) ([c7f1614](c7f161413c))
-   **all:** Auto-regenerate discovery clients ([#&#8203;2622](https://github.com/googleapis/google-api-go-client/issues/2622)) ([0077748](007774894a))

##### Bug Fixes

-   Add another temporary dep on genproto ([#&#8203;2614](https://github.com/googleapis/google-api-go-client/issues/2614)) ([4f98211](4f9821115b)), refs [#&#8203;2559](https://github.com/googleapis/google-api-go-client/issues/2559) [#&#8203;2613](https://github.com/googleapis/google-api-go-client/issues/2613)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #9
2024-07-06 20:56:48 +00:00
4549a21b9a fix(deps): update module golang.org/x/oauth2 to v0.21.0 (#8)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| golang.org/x/oauth2 | require | minor | `v0.20.0` -> `v0.21.0` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #8
2024-07-06 20:56:39 +00:00
007dcd39e3 chore(deps): update actions/checkout action to v4 (#11)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://github.com/actions/checkout) | action | major | `v3` -> `v4` |

---

### Release Notes

<details>
<summary>actions/checkout (actions/checkout)</summary>

### [`v4`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v417)

[Compare Source](https://github.com/actions/checkout/compare/v3...v4)

-   Bump the minor-npm-dependencies group across 1 directory with 4 updates by [@&#8203;dependabot](https://github.com/dependabot) in https://github.com/actions/checkout/pull/1739
-   Bump actions/checkout from 3 to 4 by [@&#8203;dependabot](https://github.com/dependabot) in https://github.com/actions/checkout/pull/1697
-   Check out other refs/\* by commit by [@&#8203;orhantoy](https://github.com/orhantoy) in https://github.com/actions/checkout/pull/1774
-   Pin actions/checkout's own workflows to a known, good, stable version. by [@&#8203;jww3](https://github.com/jww3) in https://github.com/actions/checkout/pull/1776

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #11
2024-07-06 20:56:31 +00:00
a156f078aa fix(deps): update module gfx.cafe/util/go/fxplus to v1 (#15)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| gfx.cafe/util/go/fxplus | require | major | `v0.0.0-20231226111635-bc00a6a250fb` -> `v1.11.1` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #15
2024-07-06 20:56:27 +00:00
58f09618e9 fix(deps): update module gfx.cafe/util/go/frand to v1 (#14)
Some checks failed
builder / build (push) Has been cancelled
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| gfx.cafe/util/go/frand | require | major | `v0.0.0-20231226111635-bc00a6a250fb` -> `v1.11.1` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #14
2024-07-06 20:51:15 +00:00
49880d6831 chore(deps): update goreleaser/goreleaser-action action to v6 (#13)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | action | major | `v5` -> `v6` |

---

### Release Notes

<details>
<summary>goreleaser/goreleaser-action (goreleaser/goreleaser-action)</summary>

### [`v6`](https://github.com/goreleaser/goreleaser-action/compare/v5...v6)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v5...v6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #13
2024-07-06 20:51:10 +00:00
c3b56d8e7e chore(deps): update actions/setup-go action to v5 (#12)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-go](https://github.com/actions/setup-go) | action | major | `v3` -> `v5` |

---

### Release Notes

<details>
<summary>actions/setup-go (actions/setup-go)</summary>

### [`v5`](https://github.com/actions/setup-go/compare/v4...v5)

[Compare Source](https://github.com/actions/setup-go/compare/v4...v5)

### [`v4`](https://github.com/actions/setup-go/compare/v3...v4)

[Compare Source](https://github.com/actions/setup-go/compare/v3...v4)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #12
2024-07-06 20:51:06 +00:00
efc8ebb943 fix(deps): update module modernc.org/sqlite to v1.30.1 (#10)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | require | minor | `v1.29.10` -> `v1.30.1` |

---

### Release Notes

<details>
<summary>cznic/sqlite (modernc.org/sqlite)</summary>

### [`v1.30.1`](https://gitlab.com/cznic/sqlite/compare/v1.30.0...v1.30.1)

[Compare Source](https://gitlab.com/cznic/sqlite/compare/v1.30.0...v1.30.1)

### [`v1.30.0`](https://gitlab.com/cznic/sqlite/compare/v1.29.10...v1.30.0)

[Compare Source](https://gitlab.com/cznic/sqlite/compare/v1.29.10...v1.30.0)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #10
2024-07-06 20:51:00 +00:00
54e6ec39d3 fix(deps): update module golang.org/x/net to v0.27.0 (#7)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| golang.org/x/net | require | minor | `v0.25.0` -> `v0.27.0` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #7
2024-07-06 20:50:47 +00:00
5e2327771a fix(deps): update module go.uber.org/fx to v1.22.1 (#6)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go.uber.org/fx](https://github.com/uber-go/fx) | require | patch | `v1.22.0` -> `v1.22.1` |

---

### Release Notes

<details>
<summary>uber-go/fx (go.uber.org/fx)</summary>

### [`v1.22.1`](https://github.com/uber-go/fx/releases/tag/v1.22.1)

[Compare Source](https://github.com/uber-go/fx/compare/v1.22.0...v1.22.1)

##### Fixed

-   Fx apps will only listen to signals when `.Run()`, `.Wait()`, or `.Done()`
    are called, fixing a regression introduced in v1.19.0.

Thank you [@&#8203;MarcoPolo](https://github.com/MarcoPolo) for your contribution to the release.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #6
2024-07-06 20:50:41 +00:00
2760c3ae93 fix(deps): update github.com/rivo/tview digest to b0a7293 (#5)
Some checks are pending
builder / build (push) Waiting to run
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [github.com/rivo/tview](https://github.com/rivo/tview) | require | digest | `037df49` -> `b0a7293` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #5
2024-07-06 20:50:37 +00:00
8347c24fbe Update renovate.json
All checks were successful
builder / build (push) Successful in 35s
2024-07-06 20:33:53 +00:00
a90f7d2ce9 chore(deps): update golang.org/x/exp digest to 7f521ea (#2)
All checks were successful
builder / build (push) Successful in 44s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| golang.org/x/exp | require | digest | `404ba88` -> `7f521ea` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #2
2024-07-06 20:26:16 +00:00
4d4038fd99 chore(deps): update module github.com/charmbracelet/bubbletea to v0.26.6 (#3)
Some checks failed
builder / build (push) Has been cancelled
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [github.com/charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea) | require | patch | `v0.26.4` -> `v0.26.6` |

---

### Release Notes

<details>
<summary>charmbracelet/bubbletea (github.com/charmbracelet/bubbletea)</summary>

### [`v0.26.6`](https://github.com/charmbracelet/bubbletea/releases/tag/v0.26.6)

[Compare Source](https://github.com/charmbracelet/bubbletea/compare/v0.26.5...v0.26.6)

#### Changelog

##### Bug fixes

-   [`60a57ea`](60a57eaf1f): fix: nil deref on release terminal ([@&#8203;aymanbagabas](https://github.com/aymanbagabas))

***

<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>

Thoughts? Questions? We love hearing from you. Feel free to reach out on [Twitter](https://twitter.com/charmcli), [The Fediverse](https://mastodon.technology/@&#8203;charm), or on [Discord](https://charm.sh/chat).

### [`v0.26.5`](https://github.com/charmbracelet/bubbletea/releases/tag/v0.26.5)

[Compare Source](https://github.com/charmbracelet/bubbletea/compare/v0.26.4...v0.26.5)

Fix special keys input handling on Windows using the latest Windows Console Input driver.

#### Changelog

##### New Features

-   [`42a7dd8`](42a7dd8f89): feat(ci): use goreleaser for releases ([#&#8203;1023](https://github.com/charmbracelet/bubbletea/issues/1023)) ([@&#8203;aymanbagabas](https://github.com/aymanbagabas))

##### Bug fixes

-   [`a08802e`](a08802ea9f): fix(windows): coninput not handling control sequences ([#&#8203;1041](https://github.com/charmbracelet/bubbletea/issues/1041)) ([@&#8203;Sculas](https://github.com/Sculas))

##### Other work

-   [`2d65ed6`](2d65ed65a3): chore(examples): removed use of deprecated Copy ([@&#8203;arianizadi](https://github.com/arianizadi))

***

<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>

Thoughts? Questions? We love hearing from you. Feel free to reach out on [Twitter](https://twitter.com/charmcli), [The Fediverse](https://mastodon.technology/@&#8203;charm), or on [Discord](https://charm.sh/chat).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MjQuMyIsInVwZGF0ZWRJblZlciI6IjM3LjQyNC4zIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #3
2024-07-06 20:26:00 +00:00
5e40c8e2a2 chore: Configure Renovate (#1)
All checks were successful
builder / build (push) Successful in 36s
Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin.

🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged.

---
### Detected Package Files

 * `.gitea/workflows/push.yaml` (github-actions)
 * `.gitea/workflows/releaser.yaml` (github-actions)
 * `go.mod` (gomod)

### What to Expect

With your current configuration, Renovate will create 9 Pull Requests:

<details>
<summary>chore(deps): update golang.org/x/exp digest to 7f521ea</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/golang.org-x-exp-digest`
  - Merge into: `master`
  - Upgrade golang.org/x/exp to `7f521ea00fb8`

</details>

<details>
<summary>chore(deps): update module github.com/charmbracelet/bubbletea to v0.26.6</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/github.com-charmbracelet-bubbletea-0.x`
  - Merge into: `master`
  - Upgrade [github.com/charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea) to `v0.26.6`

</details>

<details>
<summary>chore(deps): update module go.uber.org/fx to v1.22.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/go.uber.org-fx-1.x`
  - Merge into: `master`
  - Upgrade [go.uber.org/fx](https://github.com/uber-go/fx) to `v1.22.1`

</details>

<details>
<summary>chore(deps): update module golang.org/x/net to v0.27.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/golang.org-x-net-0.x`
  - Merge into: `master`
  - Upgrade golang.org/x/net to `v0.27.0`

</details>

<details>
<summary>chore(deps): update module golang.org/x/oauth2 to v0.21.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/golang.org-x-oauth2-0.x`
  - Merge into: `master`
  - Upgrade golang.org/x/oauth2 to `v0.21.0`

</details>

<details>
<summary>chore(deps): update module google.golang.org/api to v0.187.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/google.golang.org-api-0.x`
  - Merge into: `master`
  - Upgrade [google.golang.org/api](https://github.com/googleapis/google-api-go-client) to `v0.187.0`

</details>

<details>
<summary>chore(deps): update module modernc.org/sqlite to v1.30.1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/modernc.org-sqlite-1.x`
  - Merge into: `master`
  - Upgrade [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) to `v1.30.1`

</details>

<details>
<summary>chore(deps): update module gfx.cafe/util/go/frand to v1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/gfx.cafe-util-go-frand-1.x`
  - Merge into: `master`
  - Upgrade gfx.cafe/util/go/frand to `5db68942734eba89e6f024a5d644d0b787c2587a`

</details>

<details>
<summary>chore(deps): update module gfx.cafe/util/go/fxplus to v1</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/gfx.cafe-util-go-fxplus-1.x`
  - Merge into: `master`
  - Upgrade gfx.cafe/util/go/fxplus to `5db68942734eba89e6f024a5d644d0b787c2587a`

</details>

🚸 Branch creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See docs for `prhourlylimit` for details.

---

 Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).

<!--renovate-config-hash:94693a990c975907e7f13da3309b9d56ba02b3983519b41786edf5cf031e457c-->

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Reviewed-on: #1
2024-07-06 20:11:10 +00:00
2a28c74caf
tview
All checks were successful
builder / build (push) Successful in 38s
2024-05-31 18:16:20 -07:00
c1ac26e684
update
All checks were successful
builder / build (push) Successful in 1m17s
2024-05-30 15:19:52 -07:00
8a21c7cb52
error out on invalid arguments
All checks were successful
builder / build (push) Successful in 1m10s
deployer / go-releaser (push) Successful in 2m3s
2024-04-07 10:43:58 -07:00
a7152075fa
job names
All checks were successful
builder / build (push) Successful in 23s
deployer / go-releaser (push) Successful in 55s
2024-04-06 11:17:37 -07:00
c28448c295
Gitea token
All checks were successful
build-binary / Build (push) Successful in 23s
deployer / Build (push) Successful in 1m1s
2024-04-06 10:48:25 -07:00
ec69919ec6
Gitea token
All checks were successful
build-binary / Build (push) Successful in 21s
deployer / Build (push) Successful in 1m41s
2024-04-06 10:45:34 -07:00
dd2284770d
remove
Some checks failed
build-binary / Build (push) Successful in 1m1s
deployer / Build (push) Failing after 25s
2024-04-06 10:42:15 -07:00
52dbe52c50
maybe
Some checks failed
build-binary / Build (push) Has been cancelled
ci/woodpecker/push/woodpecker Pipeline was successful
2024-04-06 10:41:58 -07:00
bdec590110
build
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Build / Build (push) Successful in 1m23s
2024-04-06 10:28:14 -07:00
b516cb8d2f
build
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Build / Build (push) Failing after 2s
2024-04-06 09:46:14 -07:00
bef9898bf2
better completions
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-04-02 21:30:26 -07:00
786ee38671
build
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-03-29 18:40:06 -07:00
823774f7bd
dependency bump 2024-03-29 16:49:10 -07:00
ba07377ca8
chore: dep updates 2024-03-26 12:23:17 -07:00
dc2c74dd4b
move
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-02-23 12:48:32 -08:00
c26d11299a
make border full terminal size
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-02-21 22:13:09 -08:00
c1d4a0b5e0
fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-02-20 18:58:40 -08:00
338bef9f75
toggle
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/tag/woodpecker Pipeline failed
2024-02-20 09:00:57 -08:00
bafaa8a941
make
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-02-19 18:00:40 -08:00
e6344bef01
cleanup
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-02-19 17:58:05 -08:00
ae4291032e
license
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-19 17:21:17 -08:00
ad77e043d6
readme
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-19 13:33:36 -08:00
2116052d5e
auto retry
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-19 10:26:44 -08:00
1faea3fd7f
v3
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-19 10:11:47 -08:00
f6f7d3bd70
get client only when needed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-02-18 23:33:32 -08:00
86c2c38dfe
log settings
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-02-18 22:17:28 -08:00
59fff8dfef
cleanup
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 20:00:15 -08:00
8e067ac28d
nevermind
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-02-18 19:04:37 -08:00
c474690f3e
releaser
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-02-18 19:02:05 -08:00
d955ddaffb
releaser
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-02-18 18:56:23 -08:00
06f1974cd6
ignore
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-02-18 18:53:02 -08:00
affb5fbaba
refac
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-02-18 18:51:25 -08:00
567fb54ac2
Default command
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-02-18 15:17:20 -08:00
ad37b573cd
up
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-02-18 15:15:08 -08:00
3b8a51db39
tui exists again
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 15:04:34 -08:00
fa06934f31
Default
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 12:26:22 -08:00
1b7090084f
cli done
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 12:17:07 -08:00
a4b6f7bc40
youtube
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-02-18 12:08:31 -08:00
122c330c44
categories
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 11:59:39 -08:00
775331ce69
more cache
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 11:56:07 -08:00
aaa467eee0
cache
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 11:48:33 -08:00
e56ffc60a3
logging
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 11:34:08 -08:00
1747097e7a
setdevice
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 11:14:21 -08:00
e88a964b3a
setdevice 2024-02-18 11:10:36 -08:00
ba2b6f9952
playurl
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 10:50:43 -08:00
162574aa62
better
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 10:46:59 -08:00
8fccb0886f
flags
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 10:23:19 -08:00
7ecb57a8fd
build
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 10:10:42 -08:00
cc9b6481aa
build
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 10:02:43 -08:00
e1b1a0056a
radio
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 09:58:47 -08:00
0b5e21a6f5
makefile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-18 09:19:18 -08:00
80164f126a
version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-02-18 09:10:43 -08:00
4b5f7baeb9
remove depricated
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-02-18 09:03:25 -08:00
54 changed files with 4198 additions and 305 deletions

View 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

View 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
View File

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

82
.golangci.yml Normal file
View 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)"

View File

@ -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,11 +32,8 @@ archives:
format_overrides:
- goos: windows
format: zip
files:
- completions/*
rlcp: true
checksum:
name_template: 'checksums.txt'
name_template: "checksums.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
@ -45,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

View File

@ -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
View 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.

View File

@ -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
View 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
View 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
View 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

View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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
View File

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

View File

@ -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
View 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)
}

View File

@ -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()
}

View File

@ -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
}

View 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))
}

View 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
}

View File

@ -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
}

View 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")
}

View File

@ -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
}

View File

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

View File

@ -3,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
}

View File

@ -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: &current.PlaybackContext.URI,
PlaybackOffset: &spotify.PlaybackOffset{
Position: &pos,
},
})
case "album":
found := false
currentTrackIndex := 0
page := 1
for !found {
playlist, err := c.Client().
GetAlbumTracks(
c.Context,
spotify.ID(strings.Split(string(current.PlaybackContext.URI), ":")[2]),
spotify.Limit(50),
spotify.Offset((page-1)*50),
)
if err != nil {
return err
}
for idx, track := range playlist.Tracks {
if track.ID == current.Item.ID {
currentTrackIndex = idx + (50 * (page - 1))
found = true
break
}
}
page++
}
pos := currentTrackIndex + amt
return c.Client().PlayOpt(c.Context, &spotify.PlayOptions{
PlaybackContext: &current.PlaybackContext.URI,
PlaybackOffset: &spotify.PlaybackOffset{
Position: &pos,
},
})
default:
for i := 0; i < amt; i++ {
err := c.Client().Next(c.Context)
if err != nil {
return err
}
}
}
return nil
}

View File

@ -2,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
}

View File

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

View File

@ -1,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
}

View 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))
}

View File

@ -5,15 +5,15 @@ import (
)
func (c *Commander) Previous() error {
err := c.Client.Previous(c.Context)
err := c.Client().Previous(c.Context)
if err != nil {
if isNoActiveError(err) {
deviceId, err := c.activateDevice(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

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@ -1,12 +1,12 @@
package commands
func (c *Commander) TogglePlay() error {
state, err := c.Client.PlayerState(c.Context)
state, err := c.Client().PlayerState(c.Context)
if err != nil {
return 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()
}

View File

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

View File

@ -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)
}

View 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
}

View 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]",
})),
}
}

View 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
View 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
View 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
View 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, "")
}

View 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
}

View 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)
}

View File

@ -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"`
}

View File

@ -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
View 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
},
),
)