├── .gitignore
├── Build
└── buildlinux
├── LICENSE
├── README.md
├── cmd
└── buttercup
│ └── main.go
├── go.mod
├── go.sum
├── internal
├── buttercup.go
├── config.go
├── jackett.go
├── logger.go
├── player.go
├── rofi.go
├── selectionMenu.go
├── structs.go
├── torrentStreamer.go
├── torrentTracking.go
└── utils.go
├── jackett
├── 1337x.json
└── nyaasi.json
└── rofi
├── select.rasi
├── selectPreview.rasi
└── userInput.rasi
/.gitignore:
--------------------------------------------------------------------------------
1 | /.torrent.db
2 | /.torrent.db-shm
3 | /.torrent.db-wal
4 | buttercup
5 | buttercup.upx
6 | main
7 | requirements
8 |
--------------------------------------------------------------------------------
/Build/buildlinux:
--------------------------------------------------------------------------------
1 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o buttercup -ldflags="-s -w" -trimpath cmd/buttercup/main.go
2 | upx --best --ultra-brute buttercup
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Rushikesh Gaikwad
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # buttercup
3 |
4 | A cli application to stream torrents and track the playback using [jackett](https://github.com/Jackett/Jackett) and [webtorrent-cli](https://github.com/webtorrent/webtorrent-cli)
5 |
6 | ## Join the discord server
7 |
8 | https://discord.gg/cNaNVEE3B6
9 |
10 | ## Demo
11 |
12 | The demo speed has been increased, real speed would be slower depending on number of seeders and leechers
13 |
14 | Cli Mode:
15 |
16 | https://github.com/user-attachments/assets/59a17262-46b8-4cdd-bd79-3c362d72f2c6
17 |
18 | Rofi Mode:
19 |
20 | https://github.com/user-attachments/assets/c2c18047-b76a-4c98-a00d-fdab9e8fde5c
21 |
22 |
23 | ## Features
24 | - Search for torrents
25 | - Stream torrents
26 | - Track playback
27 | - Save MPV Speed
28 | - Download / Install Jackett
29 | - Config file
30 | - CLI Selection menu
31 | - Rofi Support
32 |
33 | ## Installing and Setup
34 | > **Note**: `buttercup` requires `mpv`, `rofi`, and `webtorrent-cli` for Rofi support and torrent streaming. These are included in the installation instructions below for each distribution.
35 |
36 | ### Linux
37 |
38 | Arch Linux / Manjaro (AUR-based systems)
39 |
40 | Using Yay:
41 |
42 | ```bash
43 | yay -Sy buttercup
44 | ```
45 |
46 | or using Paru:
47 |
48 | ```bash
49 | paru -Sy buttercup
50 | ```
51 |
52 | Or, to manually clone and install:
53 |
54 | ```bash
55 | git clone https://aur.archlinux.org/buttercup.git
56 | cd buttercup
57 | makepkg -si
58 | sudo pacman -S rofi npm
59 | npm install -g webtorrent-cli
60 | ```
61 |
62 |
63 |
64 | Debian / Ubuntu (and derivatives)
65 |
66 | ```bash
67 | sudo apt update
68 | sudo apt install -y mpv curl rofi npm
69 | sudo npm install -g webtorrent-cli
70 | curl -Lo buttercup https://github.com/Wraient/buttercup/releases/latest/download/buttercup
71 | chmod +x buttercup
72 | sudo mv buttercup /usr/local/bin/
73 | buttercup
74 | ```
75 |
76 |
77 |
78 | Fedora Installation
79 |
80 | ```bash
81 | sudo dnf update
82 | sudo dnf install -y mpv curl rofi npm
83 | sudo npm install -g webtorrent-cli
84 | curl -Lo buttercup https://github.com/Wraient/buttercup/releases/latest/download/buttercup
85 | chmod +x buttercup
86 | sudo mv buttercup /usr/local/bin/
87 | buttercup
88 | ```
89 |
90 |
91 |
92 | openSUSE Installation
93 |
94 | ```bash
95 | sudo zypper refresh
96 | sudo zypper install -y mpv curl rofi npm
97 | sudo npm install -g webtorrent-cli
98 | curl -Lo buttercup https://github.com/Wraient/buttercup/releases/latest/download/buttercup
99 | chmod +x buttercup
100 | sudo mv buttercup /usr/local/bin/
101 | buttercup
102 | ```
103 |
104 |
105 |
106 | Generic Installation
107 |
108 | ```bash
109 | # Install mpv, curl, rofi, npm, and webtorrent-cli (required for torrent streaming)
110 | # Install npm for any additional packages
111 |
112 | curl -Lo buttercup https://github.com/Wraient/buttercup/releases/latest/download/buttercup
113 | chmod +x buttercup
114 | sudo mv buttercup /usr/local/bin/
115 | buttercup
116 | ```
117 |
118 |
119 |
120 | Uninstallation
121 |
122 | ```bash
123 | sudo rm /usr/local/bin/buttercup
124 | ```
125 |
126 | For AUR-based distributions:
127 |
128 | ```bash
129 | yay -R buttercup
130 | ```
131 |
132 |
133 | ## Usage
134 |
135 | Run `buttercup` with the following options:
136 |
137 | ```bash
138 | buttercup [options]
139 | ```
140 |
141 | ### Options
142 |
143 |
144 |
145 | ### Examples
146 |
147 | - **Play with Rofi**:
148 | ```bash
149 | buttercup -rofi
150 | ```
151 |
152 | ## Configuration
153 |
154 | All configurations are stored in a file you can edit with the `-e` option.
155 |
156 | ```bash
157 | buttercup -e
158 | ```
159 |
160 | Script is made in a way that you use it for one session of watching.
161 |
162 | You can quit it anytime and the resume time would be saved in the history file
163 |
164 | more settings can be found at config file.
165 | config file is located at ```~/.config/buttercup/config```
166 |
167 | ## Dependencies
168 | - mpv - Video player (vlc support might be added later)
169 | - rofi - Selection menu
170 | - tar - Download and unzip Jackett
171 |
172 | ## API Used
173 | - [Jackett](https://github.com/Jackett/Jackett) - To get torrents
174 |
--------------------------------------------------------------------------------
/cmd/buttercup/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "flag"
6 | "fmt"
7 | "os"
8 | "os/exec"
9 | "os/signal"
10 | "path/filepath"
11 | "runtime"
12 | "strconv"
13 | "strings"
14 | "syscall"
15 | "time"
16 |
17 | "Github.com/wraient/buttercup/internal"
18 | )
19 |
20 | func main() {
21 |
22 | configPath := os.ExpandEnv("$HOME/.config/buttercup/config")
23 |
24 | // Load config from default location
25 | // internal.Debug("Loading config from default location")
26 | config, err := internal.LoadConfig(configPath)
27 | if err != nil {
28 | internal.Exit("Failed to load config", err)
29 | }
30 |
31 | flag.BoolVar(&config.SaveMpvSpeed, "save-mpv-speed", config.SaveMpvSpeed, "Save MPV speed setting (true/false)")
32 | debug := flag.Bool("debug", false, "Enable debug logging")
33 | rofiSelection := flag.Bool("rofi", false, "Open selection in rofi")
34 | noRofi := flag.Bool("no-rofi", false, "No rofi")
35 | updateScript := flag.Bool("u", false, "Update the script")
36 | editConfig := flag.Bool("e", false, "Edit configuration file")
37 | flag.Parse()
38 |
39 | internal.InitLogger(*debug)
40 |
41 | if *updateScript {
42 | repo := "wraient/buttercup"
43 | fileName := "buttercup"
44 |
45 | if err := internal.UpdateButtercup(repo, fileName); err != nil {
46 | internal.Exit("Error updating executable", err)
47 | } else {
48 | internal.Exit("Program Updated!", nil)
49 | }
50 | }
51 |
52 | // Handle config editing flag
53 | if *editConfig {
54 | cmd := exec.Command("vim", configPath)
55 | cmd.Stdin = os.Stdin
56 | cmd.Stdout = os.Stdout
57 | cmd.Stderr = os.Stderr
58 |
59 | if err := cmd.Run(); err != nil {
60 | internal.Exit("Failed to open config in vim", err)
61 | }
62 | return
63 | }
64 |
65 | if *rofiSelection {
66 | config.RofiSelection = true
67 | }
68 |
69 | if *noRofi || runtime.GOOS != "linux" {
70 | config.RofiSelection = false
71 | }
72 |
73 | if config.RofiSelection {
74 | // Define a slice of file names to check and download
75 | filesToCheck := []string{
76 | "selectPreview.rasi",
77 | "select.rasi",
78 | "userInput.rasi",
79 | }
80 |
81 | // Call the function to check and download files
82 | err := internal.CheckAndDownloadFiles(os.ExpandEnv(config.StoragePath), filesToCheck)
83 | if err != nil {
84 | internal.Exit("Error checking and downloading files", err)
85 | }
86 | }
87 |
88 | // Check if Jackett is available
89 | if err := internal.CheckJackettAvailability(&config); err != nil {
90 | internal.Debug("Jackett not available")
91 | if config.RunJackettAtStartup {
92 | internal.Info("Starting Jackett service...")
93 | err := internal.StartJackett()
94 | if err != nil {
95 | internal.Info("Failed to start Jackett", err)
96 | }
97 | }
98 |
99 | if err := internal.CheckJackettAvailability(&config); err != nil {
100 |
101 | // Create options map for Jackett setup menu
102 | jackettOptions := map[string]string{
103 | "1": "Install Jackett",
104 | "2": "Configure Jackett URL and API key manually",
105 | }
106 |
107 | selected, err := internal.DynamicSelect(jackettOptions)
108 | if err != nil {
109 | internal.Exit("Error showing Jackett setup menu", err)
110 | }
111 |
112 | switch selected.Key {
113 | case "1":
114 | if err := internal.InstallJackett(); err != nil {
115 | internal.Exit("Failed to install Jackett", err)
116 | }
117 | internal.Info("Starting Jackett service...")
118 | err := internal.StartJackett()
119 | if err != nil {
120 | internal.Exit("Failed to start Jackett", err)
121 | }
122 | if err := internal.CheckJackettAvailability(&config); err != nil {
123 | internal.Exit("Failed to check Jackett availability", err)
124 | }
125 |
126 | internal.Info("Getting Jackett API key...")
127 | apiKey, err := internal.GetJackettApiKey()
128 | if err != nil {
129 | internal.Exit("Failed to get Jackett API key", err)
130 | }
131 | internal.Info("Jackett API key: %s", apiKey)
132 |
133 | config.JackettApiKey = apiKey
134 | internal.SetGlobalConfig(&config)
135 |
136 | // Save updated config
137 | if err := internal.SaveConfig(configPath, config); err != nil {
138 | internal.Exit("Failed to save config", err)
139 | }
140 |
141 | case "2":
142 | if config.RofiSelection {
143 | config.JackettUrl, err = internal.GetUserInputFromRofi("Enter Jackett URL (e.g., 127.0.0.1)")
144 | if err != nil {
145 | internal.Exit("Failed to get Jackett URL", err)
146 | }
147 |
148 | config.JackettPort, err = internal.GetUserInputFromRofi("Enter Jackett Port (e.g., 9117)")
149 | if err != nil {
150 | internal.Exit("Failed to get Jackett Port", err)
151 | }
152 |
153 | config.JackettApiKey, err = internal.GetUserInputFromRofi("Enter Jackett API Key")
154 | if err != nil {
155 | internal.Exit("Failed to get Jackett API Key", err)
156 | }
157 | } else {
158 | fmt.Print("Enter Jackett URL (e.g., 127.0.0.1): ")
159 | fmt.Scanln(&config.JackettUrl)
160 |
161 | fmt.Print("Enter Jackett Port (e.g., 9117): ")
162 | fmt.Scanln(&config.JackettPort)
163 |
164 | fmt.Print("Enter Jackett API Key: ")
165 | fmt.Scanln(&config.JackettApiKey)
166 | }
167 |
168 | // Save the updated config
169 | if err := internal.SaveConfig(configPath, config); err != nil {
170 | internal.Exit("Failed to save config", err)
171 | }
172 |
173 | default:
174 | internal.Exit("No selection made", nil)
175 | }
176 | }
177 | }
178 |
179 | if config.RunJackettAtStartup {
180 | // Get Jackett API key and store it in config
181 | if config.JackettApiKey == "" {
182 | internal.Info("Getting Jackett API key...")
183 | apiKey, err := internal.GetJackettApiKey()
184 | if err != nil {
185 | internal.Exit("Failed to get Jackett API key", err)
186 | }
187 | internal.Info("Jackett API key: %s", apiKey)
188 |
189 | config.JackettApiKey = apiKey
190 | internal.SetGlobalConfig(&config)
191 |
192 | // Save updated config
193 | if err := internal.SaveConfig(configPath, config); err != nil {
194 | internal.Exit("Failed to save config", err)
195 | }
196 | }
197 | internal.SetGlobalConfig(&config)
198 | internal.Info("Jackett API key: %s", config.JackettApiKey)
199 | }
200 |
201 | internal.Debug("Config loaded successfully: %+v", config)
202 |
203 | // Load animes in database
204 | databaseFile := filepath.Join(os.ExpandEnv(config.StoragePath), "torrent_history.txt")
205 | databaseTorrents := internal.LocalGetAllTorrents(databaseFile)
206 |
207 | defer internal.CleanupWebtorrent() // Keep this as a backup
208 |
209 | // Add initial menu options
210 | initialOptions := map[string]string{
211 | "1": "Start New Show",
212 | "2": "Continue Watching",
213 | }
214 |
215 | initialSelection, err := internal.DynamicSelect(initialOptions)
216 | if err != nil {
217 | internal.Exit("Error showing initial menu", err)
218 | }
219 |
220 | var selected internal.SelectionOption
221 | var user internal.User
222 |
223 | switch initialSelection.Key {
224 | case "1":
225 | var searchQuery string
226 | // Handle new search
227 | if config.RofiSelection {
228 | searchQuery, err = internal.GetUserInputFromRofi("Enter search query")
229 | if err != nil {
230 | internal.Exit("Failed to get search query", err)
231 | }
232 | } else {
233 | reader := bufio.NewReader(os.Stdin)
234 | fmt.Print("Enter search query: ")
235 | input, _ := reader.ReadString('\n')
236 | searchQuery = strings.TrimSpace(input)
237 | }
238 | // Search Jackett with the provided query
239 | jackettResponse, err := internal.SearchJackett(searchQuery)
240 | if err != nil {
241 | internal.Exit("Error searching jackett", err)
242 | }
243 |
244 | // Check if we got any results
245 | if len(jackettResponse.Results) == 0 {
246 | internal.Exit("No results found", nil)
247 | }
248 |
249 | // Create options map for selection menu
250 | options := make(map[string]string)
251 | for i, result := range jackettResponse.Results {
252 | // Format the size to be human readable
253 | // size := internal.FormatSize(result.Size)
254 |
255 | // Format display string with pipe separation
256 | key := fmt.Sprintf("%d", i)
257 | // Format: "title|seeders|uri"
258 | options[key] = fmt.Sprintf("%s|%d|%s",
259 | result.Title,
260 | result.Seeders,
261 | result.Tracker)
262 | }
263 |
264 | // Show selection menu
265 | selected, err = internal.DynamicSelect(options)
266 | if err != nil {
267 | internal.Exit("Error showing selection menu", err)
268 | }
269 |
270 | if selected.Key == "-1" {
271 | internal.Info("No selection made, exiting")
272 | internal.Exit("No selection made, exiting", nil)
273 | }
274 |
275 | // Get the selected result using the index
276 | selectedIndex, _ := strconv.Atoi(selected.Key)
277 | selectedResult := jackettResponse.Results[selectedIndex]
278 |
279 | internal.Debug("Selected: %s", selectedResult)
280 |
281 | // Ensure the MagnetUri is correctly retrieved
282 | user.Watching.URI = selectedResult.MagnetUri
283 | if user.Watching.URI == "" {
284 | user.Watching.URI, err = internal.FetchMagnetURI(selectedResult.Guid)
285 | if err != nil {
286 | internal.Exit("Failed to retrieve magnet URI", err)
287 | }
288 | }
289 |
290 | // Get list of files in the torrent
291 | user.Watching.Files, err = internal.GetTorrentFiles(user.Watching.URI)
292 | if err != nil {
293 | internal.Exit("Failed to get torrent files", err)
294 | }
295 |
296 | // Show file selection menu for new shows only
297 | options = make(map[string]string)
298 | for i, file := range user.Watching.Files {
299 | key := fmt.Sprintf("%d", i)
300 | options[key] = file.DisplayName
301 | }
302 |
303 | // Automatically select if only one file
304 | if len(options) == 1 {
305 | internal.Info("Only one file found, selecting automatically")
306 | user.Watching.FileIndex = user.Watching.Files[0].ActualIndex
307 | } else {
308 | selected, err = internal.DynamicSelect(options)
309 | if err != nil {
310 | internal.Exit("Error showing selection menu", err)
311 | }
312 |
313 | if selected.Key == "-1" {
314 | internal.Exit("No selection made, exiting", nil)
315 | }
316 |
317 | selectedIndex, _ = strconv.Atoi(selected.Key)
318 | user.Watching.FileIndex = user.Watching.Files[selectedIndex].ActualIndex
319 | }
320 |
321 | case "2":
322 | if len(databaseTorrents) == 0 {
323 | internal.Exit("No shows in watch history", nil)
324 | }
325 | // Create options map for database selection
326 | dbOptions := make(map[string]string)
327 | for i, torrent := range databaseTorrents {
328 | dbOptions[fmt.Sprintf("%d", i)] = fmt.Sprintf("%s|%s",
329 | torrent.Title,
330 | torrent.FileName)
331 | }
332 |
333 | // Show selection menu
334 | selected, err = internal.DynamicSelect(dbOptions)
335 | if err != nil {
336 | internal.Exit("Error showing selection menu", err)
337 | }
338 |
339 | if selected.Key == "-1" {
340 | internal.Exit("No selection made, exiting", nil)
341 | }
342 |
343 | // Get the selected torrent
344 | selectedIndex, _ := strconv.Atoi(selected.Key)
345 | selectedTorrent := databaseTorrents[selectedIndex]
346 |
347 | // Set up user watching details
348 | user.Watching.URI = selectedTorrent.MagnetURI
349 | user.Watching.FileIndex = selectedTorrent.FileIndex
350 | user.Player.PlaybackTime = selectedTorrent.PlaybackTime
351 | user.Resume = true
352 |
353 | internal.Info("Resuming %s at %d seconds", selectedTorrent.FileName, user.Player.PlaybackTime)
354 | }
355 |
356 | internal.Debug("MagnetUri: %s", user.Watching.URI)
357 |
358 | // Get list of files in the torrent
359 | user.Watching.Files, err = internal.GetTorrentFiles(user.Watching.URI)
360 | if err != nil {
361 | internal.Exit("Failed to get torrent files", err)
362 | }
363 |
364 | // Start streaming directly with the selected/resumed file index
365 | user.Player.SocketPath, err = internal.StreamTorrentWebtorrent(user.Watching.URI, user.Watching.FileIndex)
366 | if err != nil {
367 | internal.Exit("Failed to stream torrent", err)
368 | }
369 |
370 | internal.Debug("MPV socket path: %s", user.Player.SocketPath)
371 |
372 | for {
373 |
374 | // Get all files and find the current one by index
375 | allFiles, err := internal.GetTorrentFiles(user.Watching.URI)
376 | if err != nil {
377 | internal.Debug(fmt.Sprintf("Error getting torrent files: %v", err))
378 | continue
379 | }
380 |
381 | // Get video duration
382 | go func() {
383 | for {
384 | if user.Player.Started {
385 | if user.Player.Duration == 0 {
386 | // Get video duration
387 | durationPos, err := internal.MPVSendCommand(user.Player.SocketPath, []interface{}{"get_property", "duration"})
388 | if err != nil {
389 | internal.Debug("Error getting video duration: " + err.Error())
390 | } else if durationPos != nil {
391 | if duration, ok := durationPos.(float64); ok {
392 | user.Player.Duration = int(duration + 0.5) // Round to nearest integer
393 | internal.Debug(fmt.Sprintf("Video duration: %d seconds", user.Player.Duration))
394 | } else {
395 | internal.Debug("Error: duration is not a float64")
396 | }
397 | }
398 | break
399 | }
400 | }
401 | time.Sleep(1 * time.Second)
402 | }
403 | }()
404 |
405 | // Set the playback speed and seek to the playback time and check if player has started
406 | go func() {
407 | for {
408 | timePos, err := internal.MPVSendCommand(user.Player.SocketPath, []interface{}{"get_property", "time-pos"})
409 | if err != nil {
410 | internal.Debug("Error getting time position: " + err.Error())
411 | } else if timePos != nil {
412 | if !user.Player.Started {
413 | internal.Debug("Player started")
414 | if user.Resume {
415 | internal.Debug("Seeking to playback time: %d", user.Player.PlaybackTime)
416 | mpvOutput, err := internal.SeekMPV(user.Player.SocketPath, user.Player.PlaybackTime)
417 | if err != nil {
418 | internal.Debug("Error seeking to playback time: " + err.Error())
419 | } else {
420 | internal.Debug("MPV output: %v", mpvOutput)
421 | }
422 | user.Resume = false
423 | }
424 | user.Player.Started = true
425 | // Set the playback speed
426 | if config.SaveMpvSpeed {
427 | speedCmd := []interface{}{"set_property", "speed", user.Player.Speed}
428 | _, err := internal.MPVSendCommand(user.Player.SocketPath, speedCmd)
429 | if err != nil {
430 | internal.Debug("Error setting playback speed: " + err.Error())
431 | }
432 | }
433 | break
434 | }
435 | }
436 | time.Sleep(1 * time.Second)
437 | }
438 | }()
439 |
440 | // Playback monitoring and database updates
441 | skipLoop:
442 | for {
443 | time.Sleep(1 * time.Second)
444 | timePos, err := internal.MPVSendCommand(user.Player.SocketPath, []interface{}{"get_property", "time-pos"})
445 | if err != nil && user.Player.Started {
446 | internal.Debug("Error getting time position: " + err.Error())
447 | // MPV closed or error occurred
448 | // Check if we reached completion percentage before starting next episode
449 | if user.Player.Started {
450 | percentage := float64(user.Player.PlaybackTime) / float64(user.Player.Duration) * 100
451 | if err != nil {
452 | internal.Debug("Error getting percentage watched: " + err.Error())
453 | }
454 | internal.Debug(fmt.Sprintf("Percentage watched: %f", percentage))
455 | internal.Debug(fmt.Sprintf("Percentage to mark complete: %d", config.PercentageToMarkCompleted))
456 | if percentage >= float64(config.PercentageToMarkCompleted) {
457 | // Sort episodes if not already sorted
458 | if user.Watching.SortedFiles == nil {
459 | // Convert TorrentFileInfo slice to string slice of display names
460 | fileNames := make([]string, len(user.Watching.Files))
461 | for i, file := range user.Watching.Files {
462 | fileNames[i] = file.DisplayName
463 | }
464 | user.Watching.SortedFiles = internal.FindAndSortEpisodes(fileNames)
465 | }
466 |
467 | // Find current episode in sorted list
468 | currentFile := user.Watching.Files[user.Watching.FileIndex]
469 | nextIndex := -1
470 | for i, file := range user.Watching.SortedFiles {
471 | if file == currentFile.DisplayName && i < len(user.Watching.SortedFiles)-1 {
472 | nextIndex = i + 1
473 | break
474 | }
475 | }
476 |
477 | if nextIndex != -1 {
478 | // Find the index in original files slice
479 | for i, file := range user.Watching.Files {
480 | if file.DisplayName == user.Watching.SortedFiles[nextIndex] {
481 | internal.Output(fmt.Sprintf("Starting next episode: %s", file.DisplayName))
482 | user.Watching.FileIndex = i
483 | user.Player.PlaybackTime = 0
484 | // Update database with new episode and reset playback time
485 | err = internal.LocalUpdateTorrent(databaseFile, user.Watching.URI, i, 0, file.DisplayName)
486 | if err != nil {
487 | internal.Debug(fmt.Sprintf("Error updating database for next episode: %v", err))
488 | }
489 | break skipLoop
490 | }
491 | }
492 | } else {
493 | internal.Output("No more episodes in series")
494 | internal.Exit("", nil)
495 | }
496 | } else {
497 | internal.Exit("", nil)
498 | }
499 | }
500 | break skipLoop // Add this to ensure we break the loop on any MPV error
501 | }
502 |
503 | // Episode started
504 | if timePos != nil && user.Player.Started {
505 | showPosition, ok := timePos.(float64)
506 | if !ok {
507 | continue
508 | }
509 |
510 | // Update playback time
511 | user.Player.PlaybackTime = int(showPosition + 0.5)
512 | user.Player.Speed, err = internal.GetMPVPlaybackSpeed(user.Player.SocketPath)
513 | if err != nil {
514 | internal.Debug(fmt.Sprintf("Error getting playback speed: %v", err))
515 | }
516 |
517 | // Find the file we're currently playing
518 | var currentFileName string
519 | for _, file := range allFiles {
520 | if file.ActualIndex == user.Watching.FileIndex {
521 | currentFileName = file.DisplayName
522 | break
523 | }
524 | }
525 |
526 | // Save to database using the current file name
527 | err = internal.LocalUpdateTorrent(databaseFile, user.Watching.URI, user.Watching.FileIndex, user.Player.PlaybackTime, currentFileName)
528 | if err != nil {
529 | internal.Debug(fmt.Sprintf("Error updating database: %v", err))
530 | }
531 | internal.Debug("Database updated successfully")
532 | }
533 | }
534 |
535 | // Start the next episode after the skipLoop if we have one
536 | if user.Player.PlaybackTime == 0 { // This indicates we're ready for next episode
537 | var err error
538 | user.Player.Duration = 0 // Reset duration for new episode
539 | user.Player.Started = false // Reset started flag
540 | user.Player.SocketPath, err = internal.StreamTorrentWebtorrent(user.Watching.URI, user.Watching.FileIndex)
541 | if err != nil {
542 | internal.Debug(fmt.Sprintf("Error starting next episode: %v", err))
543 | internal.Exit("", err)
544 | }
545 | }
546 | }
547 |
548 | // Set up signal handling
549 | sigChan := make(chan os.Signal, 1)
550 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
551 | go func() {
552 | <-sigChan
553 | internal.CleanupWebtorrent()
554 | os.Exit(0)
555 | }()
556 |
557 | }
558 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module Github.com/wraient/buttercup
2 |
3 | go 1.23.2
4 |
5 | require (
6 | github.com/Microsoft/go-winio v0.6.2
7 | github.com/anacrolix/torrent v1.57.1
8 | github.com/charmbracelet/bubbletea v1.1.2
9 | github.com/dustin/go-humanize v1.0.0
10 | golang.org/x/net v0.23.0
11 | )
12 |
13 | require (
14 | github.com/RoaringBitmap/roaring v1.2.3 // indirect
15 | github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect
16 | github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
17 | github.com/anacrolix/chansync v0.4.1-0.20240627045151-1aa1ac392fe8 // indirect
18 | github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444 // indirect
19 | github.com/anacrolix/envpprof v1.3.0 // indirect
20 | github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect
21 | github.com/anacrolix/go-libutp v1.3.1 // indirect
22 | github.com/anacrolix/log v0.15.3-0.20240627045001-cd912c641d83 // indirect
23 | github.com/anacrolix/missinggo v1.3.0 // indirect
24 | github.com/anacrolix/missinggo/perf v1.0.0 // indirect
25 | github.com/anacrolix/missinggo/v2 v2.7.4 // indirect
26 | github.com/anacrolix/mmsg v1.0.0 // indirect
27 | github.com/anacrolix/multiless v0.3.0 // indirect
28 | github.com/anacrolix/stm v0.4.0 // indirect
29 | github.com/anacrolix/sync v0.5.1 // indirect
30 | github.com/anacrolix/upnp v0.1.4 // indirect
31 | github.com/anacrolix/utp v0.1.0 // indirect
32 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
33 | github.com/bahlo/generic-list-go v0.2.0 // indirect
34 | github.com/benbjohnson/immutable v0.3.0 // indirect
35 | github.com/bits-and-blooms/bitset v1.2.2 // indirect
36 | github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
37 | github.com/cespare/xxhash v1.1.0 // indirect
38 | github.com/charmbracelet/lipgloss v0.13.0 // indirect
39 | github.com/charmbracelet/x/ansi v0.4.0 // indirect
40 | github.com/charmbracelet/x/term v0.2.0 // indirect
41 | github.com/davecgh/go-spew v1.1.1 // indirect
42 | github.com/edsrzf/mmap-go v1.1.0 // indirect
43 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
44 | github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
45 | github.com/go-llsqlite/crawshaw v0.5.2-0.20240425034140-f30eb7704568 // indirect
46 | github.com/go-logr/logr v1.2.3 // indirect
47 | github.com/go-logr/stdr v1.2.2 // indirect
48 | github.com/google/btree v1.1.2 // indirect
49 | github.com/google/uuid v1.3.0 // indirect
50 | github.com/gorilla/websocket v1.5.0 // indirect
51 | github.com/huandu/xstrings v1.3.2 // indirect
52 | github.com/klauspost/cpuid/v2 v2.2.3 // indirect
53 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
54 | github.com/mattn/go-isatty v0.0.20 // indirect
55 | github.com/mattn/go-localereader v0.0.1 // indirect
56 | github.com/mattn/go-runewidth v0.0.15 // indirect
57 | github.com/minio/sha256-simd v1.0.0 // indirect
58 | github.com/mr-tron/base58 v1.2.0 // indirect
59 | github.com/mschoch/smat v0.2.0 // indirect
60 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
61 | github.com/muesli/cancelreader v0.2.2 // indirect
62 | github.com/muesli/termenv v0.15.2 // indirect
63 | github.com/multiformats/go-multihash v0.2.3 // indirect
64 | github.com/multiformats/go-varint v0.0.6 // indirect
65 | github.com/pion/datachannel v1.5.2 // indirect
66 | github.com/pion/dtls/v2 v2.2.4 // indirect
67 | github.com/pion/ice/v2 v2.2.6 // indirect
68 | github.com/pion/interceptor v0.1.11 // indirect
69 | github.com/pion/logging v0.2.2 // indirect
70 | github.com/pion/mdns v0.0.5 // indirect
71 | github.com/pion/randutil v0.1.0 // indirect
72 | github.com/pion/rtcp v1.2.9 // indirect
73 | github.com/pion/rtp v1.7.13 // indirect
74 | github.com/pion/sctp v1.8.2 // indirect
75 | github.com/pion/sdp/v3 v3.0.5 // indirect
76 | github.com/pion/srtp/v2 v2.0.9 // indirect
77 | github.com/pion/stun v0.3.5 // indirect
78 | github.com/pion/transport v0.13.1 // indirect
79 | github.com/pion/transport/v2 v2.0.0 // indirect
80 | github.com/pion/turn/v2 v2.0.8 // indirect
81 | github.com/pion/udp v0.1.4 // indirect
82 | github.com/pion/webrtc/v3 v3.1.42 // indirect
83 | github.com/pkg/errors v0.9.1 // indirect
84 | github.com/protolambda/ctxlock v0.1.0 // indirect
85 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
86 | github.com/rivo/uniseg v0.4.7 // indirect
87 | github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
88 | github.com/spaolacci/murmur3 v1.1.0 // indirect
89 | github.com/tidwall/btree v1.6.0 // indirect
90 | go.etcd.io/bbolt v1.3.6 // indirect
91 | go.opentelemetry.io/otel v1.11.1 // indirect
92 | go.opentelemetry.io/otel/trace v1.11.1 // indirect
93 | golang.org/x/crypto v0.21.0 // indirect
94 | golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect
95 | golang.org/x/sync v0.8.0 // indirect
96 | golang.org/x/sys v0.26.0 // indirect
97 | golang.org/x/text v0.14.0 // indirect
98 | golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
99 | lukechampine.com/blake3 v1.1.6 // indirect
100 | modernc.org/libc v1.22.3 // indirect
101 | modernc.org/mathutil v1.5.0 // indirect
102 | modernc.org/memory v1.5.0 // indirect
103 | modernc.org/sqlite v1.21.1 // indirect
104 | zombiezen.com/go/sqlite v0.13.1 // indirect
105 | )
106 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
3 | crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
4 | crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
5 | filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
6 | filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
7 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
8 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
9 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
10 | github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
11 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
12 | github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
13 | github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
14 | github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
15 | github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVOVmhWBY=
16 | github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
17 | github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
18 | github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
19 | github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 h1:byYvvbfSo3+9efR4IeReh77gVs4PnNDR3AMOE9NJ7a0=
20 | github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0/go.mod h1:q37NoqncT41qKc048STsifIt69LfUJ8SrWWcz/yam5k=
21 | github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
22 | github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
23 | github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
24 | github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
25 | github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
26 | github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
27 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
28 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
29 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
30 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
31 | github.com/anacrolix/chansync v0.4.1-0.20240627045151-1aa1ac392fe8 h1:eyb0bBaQKMOh5Se/Qg54shijc8K4zpQiOjEhKFADkQM=
32 | github.com/anacrolix/chansync v0.4.1-0.20240627045151-1aa1ac392fe8/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k=
33 | github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444 h1:8V0K09lrGoeT2KRJNOtspA7q+OMxGwQqK/Ug0IiaaRE=
34 | github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444/go.mod h1:MctKM1HS5YYDb3F30NGJxLE+QPuqWoT5ReW/4jt8xew=
35 | github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
36 | github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
37 | github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
38 | github.com/anacrolix/envpprof v1.3.0 h1:WJt9bpuT7A/CDCxPOv/eeZqHWlle/Y0keJUvc6tcJDk=
39 | github.com/anacrolix/envpprof v1.3.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0=
40 | github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
41 | github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca h1:aiiGqSQWjtVNdi8zUMfA//IrM8fPkv2bWwZVPbDe0wg=
42 | github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca/go.mod h1:MN3ve08Z3zSV/rTuX/ouI4lNdlfTxgdafQJiLzyNRB8=
43 | github.com/anacrolix/go-libutp v1.3.1 h1:idJzreNLl+hNjGC3ZnUOjujEaryeOGgkwHLqSGoige0=
44 | github.com/anacrolix/go-libutp v1.3.1/go.mod h1:heF41EC8kN0qCLMokLBVkB8NXiLwx3t8R8810MTNI5o=
45 | github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
46 | github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
47 | github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=
48 | github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=
49 | github.com/anacrolix/log v0.15.3-0.20240627045001-cd912c641d83 h1:9o/yVzzLzYaBDFx8B27yhkvBLhNnRAuSTK7Y+yZKVtU=
50 | github.com/anacrolix/log v0.15.3-0.20240627045001-cd912c641d83/go.mod h1:xvHjsYWWP7yO8PZwtuIp/k0DBlu07pSJqH4SEC78Vwc=
51 | github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62 h1:P04VG6Td13FHMgS5ZBcJX23NPC/fiC4cp9bXwYujdYM=
52 | github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
53 | github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=
54 | github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
55 | github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
56 | github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
57 | github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
58 | github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
59 | github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw=
60 | github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
61 | github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
62 | github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
63 | github.com/anacrolix/missinggo/v2 v2.7.4 h1:47h5OXoPV8JbA/ACA+FLwKdYbAinuDO8osc2Cu9xkxg=
64 | github.com/anacrolix/missinggo/v2 v2.7.4/go.mod h1:vVO5FEziQm+NFmJesc7StpkquZk+WJFCaL0Wp//2sa0=
65 | github.com/anacrolix/mmsg v0.0.0-20180515031531-a4a3ba1fc8bb/go.mod h1:x2/ErsYUmT77kezS63+wzZp8E3byYB0gzirM/WMBLfw=
66 | github.com/anacrolix/mmsg v1.0.0 h1:btC7YLjOn29aTUAExJiVUhQOuf/8rhm+/nWCMAnL3Hg=
67 | github.com/anacrolix/mmsg v1.0.0/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc=
68 | github.com/anacrolix/multiless v0.3.0 h1:5Bu0DZncjE4e06b9r1Ap2tUY4Au0NToBP5RpuEngSis=
69 | github.com/anacrolix/multiless v0.3.0/go.mod h1:TrCLEZfIDbMVfLoQt5tOoiBS/uq4y8+ojuEVVvTNPX4=
70 | github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
71 | github.com/anacrolix/stm v0.4.0 h1:tOGvuFwaBjeu1u9X1eIh9TX8OEedEiEQ1se1FjhFnXY=
72 | github.com/anacrolix/stm v0.4.0/go.mod h1:GCkwqWoAsP7RfLW+jw+Z0ovrt2OO7wRzcTtFYMYY5t8=
73 | github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk=
74 | github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=
75 | github.com/anacrolix/sync v0.5.1 h1:FbGju6GqSjzVoTgcXTUKkF041lnZkG5P0C3T5RL3SGc=
76 | github.com/anacrolix/sync v0.5.1/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=
77 | github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
78 | github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
79 | github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
80 | github.com/anacrolix/torrent v1.57.1 h1:CS8rYfC2Oe15NPBhwCNs/3WBY6HiBCPDFpY+s9aFHbA=
81 | github.com/anacrolix/torrent v1.57.1/go.mod h1:NNBg4lP2/us9Hp5+cLNcZRILM69cNoKIkqMGqr9AuR0=
82 | github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=
83 | github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=
84 | github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4=
85 | github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk=
86 | github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
87 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
88 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
89 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
90 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
91 | github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
92 | github.com/benbjohnson/immutable v0.3.0 h1:TVRhuZx2wG9SZ0LRdqlbs9S5BZ6Y24hJEHTCgWHZEIw=
93 | github.com/benbjohnson/immutable v0.3.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
94 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
95 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
96 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
97 | github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
98 | github.com/bits-and-blooms/bitset v1.2.2 h1:J5gbX05GpMdBjCvQ9MteIg2KKDExr7DrgK+Yc15FvIk=
99 | github.com/bits-and-blooms/bitset v1.2.2/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
100 | github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
101 | github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
102 | github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
103 | github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
104 | github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
105 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
106 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
107 | github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm4vBfHsIc=
108 | github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E=
109 | github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
110 | github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
111 | github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU=
112 | github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
113 | github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
114 | github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
115 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
116 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
117 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
118 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
119 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
120 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
121 | github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
122 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
123 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
124 | github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
125 | github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
126 | github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
127 | github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
128 | github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
129 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
130 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
131 | github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
132 | github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
133 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
134 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
135 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
136 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
137 | github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
138 | github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
139 | github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
140 | github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
141 | github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
142 | github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
143 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
144 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
145 | github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 h1:OyQmpAN302wAopDgwVjgs2HkFawP9ahIEqkUYz7V7CA=
146 | github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916/go.mod h1:DADrR88ONKPPeSGjFp5iEN55Arx3fi2qXZeKCYDpbmU=
147 | github.com/go-llsqlite/crawshaw v0.5.2-0.20240425034140-f30eb7704568 h1:3EpZo8LxIzF4q3BT+vttQQlRfA6uTtTb/cxVisWa5HM=
148 | github.com/go-llsqlite/crawshaw v0.5.2-0.20240425034140-f30eb7704568/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE=
149 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
150 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
151 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
152 | github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
153 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
154 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
155 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
156 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
157 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
158 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
159 | github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
160 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
161 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
162 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
163 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
164 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
165 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
166 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
167 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
168 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
169 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
170 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
171 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
172 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
173 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
174 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
175 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
176 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
177 | github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
178 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
179 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
180 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
181 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
182 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
183 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
184 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
185 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
186 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
187 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
188 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
189 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
190 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
191 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
192 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
193 | github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
194 | github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
195 | github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
196 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
197 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
198 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
199 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
200 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
201 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
202 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
203 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
204 | github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
205 | github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
206 | github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
207 | github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
208 | github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
209 | github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
210 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
211 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
212 | github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
213 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
214 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
215 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
216 | github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
217 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
218 | github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
219 | github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
220 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
221 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
222 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
223 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
224 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
225 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
226 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
227 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
228 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
229 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
230 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
231 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
232 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
233 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
234 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
235 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
236 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
237 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
238 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
239 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
240 | github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
241 | github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
242 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
243 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
244 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
245 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
246 | github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
247 | github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
248 | github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
249 | github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
250 | github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
251 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
252 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
253 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
254 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
255 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
256 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
257 | github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
258 | github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
259 | github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
260 | github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
261 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
262 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
263 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
264 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
265 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
266 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
267 | github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
268 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
269 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
270 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
271 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
272 | github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
273 | github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
274 | github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
275 | github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
276 | github.com/pion/datachannel v1.5.2 h1:piB93s8LGmbECrpO84DnkIVWasRMk3IimbcXkTQLE6E=
277 | github.com/pion/datachannel v1.5.2/go.mod h1:FTGQWaHrdCwIJ1rw6xBIfZVkslikjShim5yr05XFuCQ=
278 | github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus=
279 | github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY=
280 | github.com/pion/dtls/v2 v2.2.4 h1:YSfYwDQgrxMYXLBc/m7PFY5BVtWlNm/DN4qoU2CbcWg=
281 | github.com/pion/dtls/v2 v2.2.4/go.mod h1:WGKfxqhrddne4Kg3p11FUMJrynkOY4lb25zHNO49wuw=
282 | github.com/pion/ice/v2 v2.2.6 h1:R/vaLlI1J2gCx141L5PEwtuGAGcyS6e7E0hDeJFq5Ig=
283 | github.com/pion/ice/v2 v2.2.6/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE=
284 | github.com/pion/interceptor v0.1.11 h1:00U6OlqxA3FFB50HSg25J/8cWi7P6FbSzw4eFn24Bvs=
285 | github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8=
286 | github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
287 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
288 | github.com/pion/mdns v0.0.5 h1:Q2oj/JB3NqfzY9xGZ1fPzZzK7sDSD8rZPOvcIQ10BCw=
289 | github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01g=
290 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
291 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
292 | github.com/pion/rtcp v1.2.9 h1:1ujStwg++IOLIEoOiIQ2s+qBuJ1VN81KW+9pMPsif+U=
293 | github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo=
294 | github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
295 | github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
296 | github.com/pion/sctp v1.8.0/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
297 | github.com/pion/sctp v1.8.2 h1:yBBCIrUMJ4yFICL3RIvR4eh/H2BTTvlligmSTy+3kiA=
298 | github.com/pion/sctp v1.8.2/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
299 | github.com/pion/sdp/v3 v3.0.5 h1:ouvI7IgGl+V4CrqskVtr3AaTrPvPisEOxwgpdktctkU=
300 | github.com/pion/sdp/v3 v3.0.5/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
301 | github.com/pion/srtp/v2 v2.0.9 h1:JJq3jClmDFBPX/F5roEb0U19jSU7eUhyDqR/NZ34EKQ=
302 | github.com/pion/srtp/v2 v2.0.9/go.mod h1:5TtM9yw6lsH0ppNCehB/EjEUli7VkUgKSPJqWVqbhQ4=
303 | github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg=
304 | github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA=
305 | github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q=
306 | github.com/pion/transport v0.12.3/go.mod h1:OViWW9SP2peE/HbwBvARicmAVnesphkNkCVZIWJ6q9A=
307 | github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g=
308 | github.com/pion/transport v0.13.1 h1:/UH5yLeQtwm2VZIPjxwnNFxjS4DFhyLfS4GlfuKUzfA=
309 | github.com/pion/transport v0.13.1/go.mod h1:EBxbqzyv+ZrmDb82XswEE0BjfQFtuw1Nu6sjnjWCsGg=
310 | github.com/pion/transport/v2 v2.0.0 h1:bsMYyqHCbkvHwj+eNCFBuxtlKndKfyGI2vaQmM3fIE4=
311 | github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc=
312 | github.com/pion/turn/v2 v2.0.8 h1:KEstL92OUN3k5k8qxsXHpr7WWfrdp7iJZHx99ud8muw=
313 | github.com/pion/turn/v2 v2.0.8/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw=
314 | github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M=
315 | github.com/pion/udp v0.1.4 h1:OowsTmu1Od3sD6i3fQUJxJn2fEvJO6L1TidgadtbTI8=
316 | github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us=
317 | github.com/pion/webrtc/v3 v3.1.42 h1:wJEQFIXVanptnQcHOLTuIo4AtGB2+mG2x4OhIhnITOA=
318 | github.com/pion/webrtc/v3 v3.1.42/go.mod h1:ffD9DulDrPxyWvDPUIPAOSAWx9GUlOExiJPf7cCcMLA=
319 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
320 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
321 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
322 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
323 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
324 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
325 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
326 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
327 | github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
328 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
329 | github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
330 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
331 | github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
332 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
333 | github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
334 | github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
335 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
336 | github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
337 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
338 | github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
339 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
340 | github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
341 | github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
342 | github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
343 | github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
344 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
345 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
346 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
347 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
348 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
349 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
350 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
351 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
352 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
353 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
354 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
355 | github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
356 | github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
357 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
358 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
359 | github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
360 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
361 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
362 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
363 | github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
364 | github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
365 | github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
366 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
367 | github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
368 | github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
369 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
370 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
371 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
372 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
373 | github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
374 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
375 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
376 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
377 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
378 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
379 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
380 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
381 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
382 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
383 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
384 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
385 | github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg=
386 | github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
387 | github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
388 | github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
389 | github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
390 | github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
391 | github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
392 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
393 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
394 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
395 | go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
396 | go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
397 | go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
398 | go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
399 | go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
400 | go.opentelemetry.io/otel v1.11.1 h1:4WLLAmcfkmDk2ukNXJyq3/kiz/3UzCaYq6PskJsaou4=
401 | go.opentelemetry.io/otel v1.11.1/go.mod h1:1nNhXBbWSD0nsL38H6btgnFN2k4i0sNLHNNMZMSbUGE=
402 | go.opentelemetry.io/otel/trace v1.11.1 h1:ofxdnzsNrGBYXbP7t7zpUK281+go5rF7dvdIZXF8gdQ=
403 | go.opentelemetry.io/otel/trace v1.11.1/go.mod h1:f/Q9G7vzk5u91PhbmKbg1Qn0rzH1LJ4vbPHFGkTPtOk=
404 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
405 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
406 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
407 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
408 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
409 | golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
410 | golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
411 | golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
412 | golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
413 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
414 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
415 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
416 | golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
417 | golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
418 | golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
419 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
420 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
421 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
422 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
423 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
424 | golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
425 | golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
426 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
427 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
428 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
429 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
430 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
431 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
432 | golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
433 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
434 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
435 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
436 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
437 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
438 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
439 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
440 | golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
441 | golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
442 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
443 | golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
444 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
445 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
446 | golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
447 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
448 | golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
449 | golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
450 | golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
451 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
452 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
453 | golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
454 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
455 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
456 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
457 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
458 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
459 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
460 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
461 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
462 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
463 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
464 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
465 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
466 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
467 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
468 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
469 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
470 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
471 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
472 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
473 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
474 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
475 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
476 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
477 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
478 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
479 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
480 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
481 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
482 | golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
483 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
484 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
485 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
486 | golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
487 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
488 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
489 | golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
490 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
491 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
492 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
493 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
494 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
495 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
496 | golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
497 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
498 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
499 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
500 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
501 | golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
502 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
503 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
504 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
505 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
506 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
507 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
508 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
509 | golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
510 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
511 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
512 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
513 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
514 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
515 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
516 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
517 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
518 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
519 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
520 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
521 | golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U=
522 | golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
523 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
524 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
525 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
526 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
527 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
528 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
529 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
530 | golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
531 | golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
532 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
533 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
534 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
535 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
536 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
537 | google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
538 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
539 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
540 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
541 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
542 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
543 | google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
544 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
545 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
546 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
547 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
548 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
549 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
550 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
551 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
552 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
553 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
554 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
555 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
556 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
557 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
558 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
559 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
560 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
561 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
562 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
563 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
564 | gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
565 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
566 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
567 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
568 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
569 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
570 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
571 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
572 | lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
573 | lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
574 | modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
575 | modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
576 | modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
577 | modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
578 | modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
579 | modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
580 | modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
581 | modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
582 | zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
583 | zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
584 |
--------------------------------------------------------------------------------
/internal/buttercup.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "runtime"
8 | "time"
9 | )
10 |
11 | func Exit(msg string, err error) {
12 | CleanupWebtorrent()
13 | if err != nil {
14 | fmt.Println(err)
15 | os.Exit(1)
16 | }
17 | if msg != "" {
18 | fmt.Println(msg)
19 | }
20 | os.Exit(0)
21 | }
22 |
23 | func PrintUsage() {
24 | fmt.Println("Usage: buttercup ")
25 | }
26 |
27 | // FormatSize converts bytes to human readable string with appropriate unit
28 | func FormatSize(bytes int64) string {
29 | const unit = 1024
30 | if bytes < unit {
31 | return fmt.Sprintf("%d B", bytes)
32 | }
33 |
34 | div, exp := int64(unit), 0
35 | for n := bytes / unit; n >= unit; n /= unit {
36 | div *= unit
37 | exp++
38 | }
39 |
40 | return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
41 | }
42 |
43 | func Output(data interface{}) {
44 | fmt.Println(data)
45 | }
46 |
47 | func Log(data interface{}, logFile string) error {
48 | // Open or create the log file
49 | file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
50 | if err != nil {
51 | return err
52 | }
53 | defer file.Close() // Ensure the file is closed when done
54 |
55 | // Attempt to marshal the data into JSON
56 | jsonData, err := json.Marshal(data)
57 | if err != nil {
58 | return err
59 | }
60 |
61 | // Get the caller information
62 | _, filename, lineNumber, ok := runtime.Caller(1) // Caller 1 gives the caller of LogData
63 | if !ok {
64 | return fmt.Errorf("unable to get caller information")
65 | }
66 |
67 | // Log the current time and the JSON representation along with caller info
68 | currentTime := time.Now().Format("2006/01/02 15:04:05")
69 | logMessage := fmt.Sprintf("[LOG] %s %s:%d: %s\n", currentTime, filename, lineNumber, jsonData)
70 | _, err = fmt.Fprint(file, logMessage) // Write to the file
71 | if err != nil {
72 | return err
73 | }
74 |
75 | return nil
76 | }
77 |
--------------------------------------------------------------------------------
/internal/config.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "reflect"
9 | "strconv"
10 | "strings"
11 | )
12 |
13 | // ProgramConfig struct with field names that match the config keys
14 | type ProgramConfig struct {
15 | StoragePath string `config:"StoragePath"`
16 | JackettUrl string `config:"JackettUrl"`
17 | JackettPort string `config:"JackettPort"`
18 | JackettApiKey string `config:"JackettApiKey"`
19 | RunJackettAtStartup bool `config:"RunJackettAtStartup"`
20 | RofiSelection bool `config:"RofiSelection"`
21 | PercentageToMarkCompleted int `config:"PercentageToMarkCompleted"`
22 | SaveMpvSpeed bool `config:"SaveMpvSpeed"`
23 | }
24 |
25 | // Default configuration values as a map
26 | func defaultConfigMap() map[string]string {
27 | return map[string]string{
28 | "StoragePath": "$HOME/.local/share/buttercup",
29 | "JackettUrl": "127.0.0.1",
30 | "JackettPort": "9117",
31 | "JackettApiKey": "",
32 | "RunJackettAtStartup": "true",
33 | "RofiSelection": "false",
34 | "PercentageToMarkCompleted": "92",
35 | "SaveMpvSpeed": "false",
36 | }
37 | }
38 |
39 | var globalConfig *ProgramConfig
40 |
41 | func SetGlobalConfig(config *ProgramConfig) {
42 | globalConfig = config
43 | }
44 |
45 | func GetGlobalConfig() *ProgramConfig {
46 | if globalConfig == nil {
47 | defaultConfig := defaultConfigMap()
48 | config := populateConfig(defaultConfig)
49 | return &config
50 | }
51 | return globalConfig
52 | }
53 |
54 | // LoadConfig reads or creates the config file, adds missing fields, and returns the populated ProgramConfig struct
55 | func LoadConfig(configPath string) (ProgramConfig, error) {
56 | configPath = os.ExpandEnv(configPath) // Substitute environment variables like $HOME
57 |
58 | // Check if config file exists
59 | if _, err := os.Stat(configPath); os.IsNotExist(err) {
60 | // Create the config file with default values if it doesn't exist
61 | fmt.Println("Config file not found. Creating default config...")
62 | defaultConfig := defaultConfigMap()
63 | if err := createDefaultConfig(configPath); err != nil {
64 | return ProgramConfig{}, fmt.Errorf("error creating default config file: %v", err)
65 | }
66 | // Return the default config directly
67 | return populateConfig(defaultConfig), nil
68 | }
69 |
70 | // Load the config from file
71 | configMap, err := loadConfigFromFile(configPath)
72 | if err != nil {
73 | return ProgramConfig{}, fmt.Errorf("error loading config file: %v", err)
74 | }
75 |
76 | // Add missing fields to the config map
77 | updated := false
78 | defaultConfigMap := defaultConfigMap()
79 | for key, defaultValue := range defaultConfigMap {
80 | if _, exists := configMap[key]; !exists {
81 | configMap[key] = defaultValue
82 | updated = true
83 | }
84 | }
85 |
86 | // Write updated config back to file if there were any missing fields
87 | if updated {
88 | if err := saveConfigToFile(configPath, configMap); err != nil {
89 | return ProgramConfig{}, fmt.Errorf("error saving updated config file: %v", err)
90 | }
91 | }
92 |
93 | // Populate the ProgramConfig struct from the config map
94 | config := populateConfig(configMap)
95 |
96 | return config, nil
97 | }
98 |
99 | // SaveConfig saves the current configuration to the specified path
100 | func SaveConfig(configPath string, config ProgramConfig) error {
101 | configPath = os.ExpandEnv(configPath)
102 |
103 | // Convert struct to map using reflection
104 | configMap := make(map[string]string)
105 | v := reflect.ValueOf(config)
106 | t := v.Type()
107 |
108 | for i := 0; i < v.NumField(); i++ {
109 | field := t.Field(i)
110 | tag := field.Tag.Get("config")
111 | if tag != "" {
112 | // Handle different types properly
113 | switch field.Type.Kind() {
114 | case reflect.Bool:
115 | configMap[tag] = strconv.FormatBool(v.Field(i).Bool())
116 | case reflect.Int:
117 | configMap[tag] = strconv.Itoa(int(v.Field(i).Int()))
118 | default:
119 | configMap[tag] = v.Field(i).String()
120 | }
121 | }
122 | }
123 |
124 | // Save to file using existing helper
125 | if err := saveConfigToFile(configPath, configMap); err != nil {
126 | return fmt.Errorf("error saving config: %v", err)
127 | }
128 |
129 | return nil
130 | }
131 |
132 | // Create a config file with default values in key=value format
133 | // Ensure the directory exists before creating the file
134 | func createDefaultConfig(path string) error {
135 | defaultConfig := defaultConfigMap()
136 |
137 | // Ensure the directory exists
138 | dir := filepath.Dir(path)
139 | if err := os.MkdirAll(dir, 0755); err != nil {
140 | return fmt.Errorf("error creating directory: %v", err)
141 | }
142 |
143 | file, err := os.Create(path)
144 | if err != nil {
145 | return fmt.Errorf("error creating file: %v", err)
146 | }
147 | defer file.Close()
148 |
149 | writer := bufio.NewWriter(file)
150 | for key, value := range defaultConfig {
151 | line := fmt.Sprintf("%s=%s\n", key, value)
152 | if _, err := writer.WriteString(line); err != nil {
153 | return fmt.Errorf("error writing to file: %v", err)
154 | }
155 | }
156 | if err := writer.Flush(); err != nil {
157 | return fmt.Errorf("error flushing writer: %v", err)
158 | }
159 | return nil
160 | }
161 |
162 | // Load config file from disk into a map (key=value format)
163 | func loadConfigFromFile(path string) (map[string]string, error) {
164 | file, err := os.Open(path)
165 | if err != nil {
166 | return nil, err
167 | }
168 | defer file.Close()
169 |
170 | configMap := make(map[string]string)
171 | scanner := bufio.NewScanner(file)
172 |
173 | for scanner.Scan() {
174 | line := strings.TrimSpace(scanner.Text())
175 | if line == "" || strings.HasPrefix(line, "#") {
176 | continue // Skip empty lines and comments
177 | }
178 |
179 | parts := strings.SplitN(line, "=", 2)
180 | if len(parts) == 2 {
181 | key := strings.TrimSpace(parts[0])
182 | value := strings.TrimSpace(parts[1])
183 | configMap[key] = value
184 | }
185 | }
186 |
187 | if err := scanner.Err(); err != nil {
188 | return nil, err
189 | }
190 |
191 | return configMap, nil
192 | }
193 |
194 | // Save updated config map to file in key=value format
195 | func saveConfigToFile(path string, configMap map[string]string) error {
196 | file, err := os.Create(path)
197 | if err != nil {
198 | return err
199 | }
200 | defer file.Close()
201 |
202 | writer := bufio.NewWriter(file)
203 | for key, value := range configMap {
204 | line := fmt.Sprintf("%s=%s\n", key, value)
205 | if _, err := writer.WriteString(line); err != nil {
206 | return err
207 | }
208 | }
209 | return writer.Flush()
210 | }
211 |
212 | // Populate the ProgramConfig struct from a map
213 | func populateConfig(configMap map[string]string) ProgramConfig {
214 | config := ProgramConfig{}
215 | configValue := reflect.ValueOf(&config).Elem()
216 |
217 | for i := 0; i < configValue.NumField(); i++ {
218 | field := configValue.Type().Field(i)
219 | tag := field.Tag.Get("config")
220 |
221 | if value, exists := configMap[tag]; exists {
222 | fieldValue := configValue.FieldByName(field.Name)
223 |
224 | if fieldValue.CanSet() {
225 | switch fieldValue.Kind() {
226 | case reflect.String:
227 | fieldValue.SetString(value)
228 | case reflect.Int:
229 | intVal, _ := strconv.Atoi(value)
230 | fieldValue.SetInt(int64(intVal))
231 | case reflect.Bool:
232 | boolVal, _ := strconv.ParseBool(value)
233 | fieldValue.SetBool(boolVal)
234 | }
235 | }
236 | }
237 | }
238 |
239 | return config
240 | }
241 |
--------------------------------------------------------------------------------
/internal/jackett.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "os"
9 | "path/filepath"
10 | "strings"
11 | "net/url"
12 | "golang.org/x/net/html"
13 | "os/exec"
14 | "time"
15 | )
16 |
17 | func sanitizeQuery(query string) string {
18 | // Trim spaces and convert to lowercase
19 | query = strings.TrimSpace(strings.ToLower(query))
20 |
21 | // Replace multiple spaces with single space
22 | query = strings.Join(strings.Fields(query), " ")
23 |
24 | // URL encode the query
25 | return url.QueryEscape(query)
26 | }
27 |
28 | func SearchJackett(query string) (*JackettResponse, error) {
29 | config := GetGlobalConfig()
30 |
31 | // Sanitize the search query
32 | sanitizedQuery := sanitizeQuery(query)
33 |
34 | // Build the Jackett API URL using config values
35 | jackettURL := fmt.Sprintf("http://%s:%s/api/v2.0/indexers/all/results",
36 | config.JackettUrl,
37 | config.JackettPort)
38 |
39 | // Create the request
40 | req, err := http.NewRequest("GET", jackettURL, nil)
41 | if err != nil {
42 | return nil, fmt.Errorf("failed to create request: %w", err)
43 | }
44 |
45 | // Add query parameters
46 | q := req.URL.Query()
47 | q.Add("apikey", config.JackettApiKey)
48 | q.Add("Query", sanitizedQuery)
49 | req.URL.RawQuery = q.Encode()
50 |
51 | // Make the request
52 | client := &http.Client{}
53 | resp, err := client.Do(req)
54 | if err != nil {
55 | return nil, fmt.Errorf("failed to make request: %w", err)
56 | }
57 | defer resp.Body.Close()
58 |
59 | // Check response status
60 | if resp.StatusCode != http.StatusOK {
61 | return nil, fmt.Errorf("bad response from Jackett server: %d", resp.StatusCode)
62 | }
63 |
64 | // Read the response body
65 | body, err := io.ReadAll(resp.Body)
66 | if err != nil {
67 | return nil, fmt.Errorf("failed to read response body: %w", err)
68 | }
69 |
70 | // Parse and handle response
71 | var response JackettResponse
72 | if err := json.Unmarshal(body, &response); err != nil {
73 | return nil, fmt.Errorf("failed to parse response: %w", err)
74 | }
75 |
76 | Debug("Search results: %s", string(body))
77 | return &response, nil
78 | }
79 |
80 | func GetJackettApiKey() (string, error) {
81 | // Jackett config is typically stored in ~/.config/Jackett/ServerConfig.json
82 | homeDir, err := os.UserHomeDir()
83 | if err != nil {
84 | return "", fmt.Errorf("failed to get home directory: %w", err)
85 | }
86 |
87 | jackettConfig := filepath.Join(homeDir, ".config", "Jackett", "ServerConfig.json")
88 | data, err := os.ReadFile(jackettConfig)
89 | if err != nil {
90 | return "", fmt.Errorf("failed to read Jackett config: %w", err)
91 | }
92 |
93 | // Parse JSON config
94 | var config struct {
95 | APIKey string `json:"APIKey"`
96 | }
97 |
98 | if err := json.Unmarshal(data, &config); err != nil {
99 | return "", fmt.Errorf("failed to parse Jackett config: %w", err)
100 | }
101 |
102 | return config.APIKey, nil
103 | }
104 |
105 | // FetchMagnetURI fetches the magnet URI from a 1337x torrent page
106 | func FetchMagnetURI(torrentURL string) (string, error) {
107 | // Make an HTTP GET request to the torrent URL
108 | resp, err := http.Get(torrentURL)
109 | if err != nil {
110 | return "", fmt.Errorf("failed to fetch torrent page: %w", err)
111 | }
112 | defer resp.Body.Close()
113 |
114 | // Parse the HTML document
115 | tokenizer := html.NewTokenizer(resp.Body)
116 | for {
117 | tokenType := tokenizer.Next()
118 | switch tokenType {
119 | case html.ErrorToken:
120 | return "", fmt.Errorf("magnet link not found")
121 | case html.StartTagToken, html.SelfClosingTagToken:
122 | token := tokenizer.Token()
123 | if token.Data == "a" {
124 | for _, attr := range token.Attr {
125 | if attr.Key == "href" && strings.HasPrefix(attr.Val, "magnet:?xt=") {
126 | return attr.Val, nil
127 | }
128 | }
129 | }
130 | }
131 | }
132 | }
133 |
134 | func CheckJackettAvailability(config *ProgramConfig) error {
135 | // Try to connect to Jackett
136 | url := fmt.Sprintf("http://%s:%s/api/v2.0/indexers/all/results/torznab/api?apikey=%s",
137 | config.JackettUrl, config.JackettPort, config.JackettApiKey)
138 |
139 | _, err := http.Get(url)
140 | if err != nil {
141 | return err
142 | }
143 | return nil
144 | }
145 |
146 | func InstallJackett() error {
147 | fmt.Println("Installing Jackett...")
148 | cmd := exec.Command("bash", "-c", `
149 | curl -L -o /tmp/jackett.tar.gz https://github.com/Jackett/Jackett/releases/latest/download/Jackett.Binaries.LinuxAMDx64.tar.gz
150 | cd /tmp && tar -xf jackett.tar.gz
151 | sudo mv /tmp/Jackett /opt/
152 | sudo ln -s /opt/Jackett/jackett /usr/local/bin/jackett
153 | `)
154 | cmd.Stdout = os.Stdout
155 | cmd.Stderr = os.Stderr
156 | return cmd.Run()
157 | }
158 |
159 | func StartJackett() error {
160 | // Install default indexers before starting Jackett
161 | if err := InstallDefaultIndexers(); err != nil {
162 | return fmt.Errorf("failed to install default indexers: %w", err)
163 | }
164 |
165 | // Open /dev/null for redirecting output
166 | devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
167 | if err != nil {
168 | return fmt.Errorf("failed to open /dev/null: %w", err)
169 | }
170 | defer devNull.Close()
171 |
172 | // Start Jackett in background with output redirected to /dev/null
173 | cmd := exec.Command("jackett")
174 | cmd.Stdout = devNull
175 | cmd.Stderr = devNull
176 | if err := cmd.Start(); err != nil {
177 | return fmt.Errorf("failed to start Jackett: %w", err)
178 | }
179 |
180 | // Wait for Jackett to be ready
181 | fmt.Println("Waiting for Jackett to start...")
182 | time.Sleep(10 * time.Second)
183 |
184 | return nil
185 | }
186 |
187 | func InstallDefaultIndexers() error {
188 | // Create Jackett indexers directory if it doesn't exist
189 | homeDir, err := os.UserHomeDir()
190 | if err != nil {
191 | return fmt.Errorf("failed to get home directory: %w", err)
192 | }
193 |
194 | indexersDir := filepath.Join(homeDir, ".config", "Jackett", "Indexers")
195 | if err := os.MkdirAll(indexersDir, 0755); err != nil {
196 | return fmt.Errorf("failed to create indexers directory: %w", err)
197 | }
198 |
199 | // Define indexers to download
200 | indexers := []string{
201 | "1337x.json",
202 | "nyaasi.json",
203 | }
204 |
205 | baseURL := "https://raw.githubusercontent.com/Wraient/buttercup/refs/heads/main/jackett"
206 |
207 | // Download each indexer
208 | for _, indexer := range indexers {
209 | // Download the file
210 | resp, err := http.Get(fmt.Sprintf("%s/%s", baseURL, indexer))
211 | if err != nil {
212 | return fmt.Errorf("failed to download %s: %w", indexer, err)
213 | }
214 | defer resp.Body.Close()
215 |
216 | if resp.StatusCode != http.StatusOK {
217 | return fmt.Errorf("failed to download %s: status code %d", indexer, resp.StatusCode)
218 | }
219 |
220 | // Create the file
221 | outPath := filepath.Join(indexersDir, indexer)
222 | out, err := os.Create(outPath)
223 | if err != nil {
224 | return fmt.Errorf("failed to create file %s: %w", indexer, err)
225 | }
226 | defer out.Close()
227 |
228 | // Copy the content
229 | if _, err := io.Copy(out, resp.Body); err != nil {
230 | return fmt.Errorf("failed to write file %s: %w", indexer, err)
231 | }
232 | }
233 |
234 | return nil
235 | }
236 |
--------------------------------------------------------------------------------
/internal/logger.go:
--------------------------------------------------------------------------------
1 | // internal/logger.go
2 | package internal
3 |
4 | import (
5 | "io"
6 | "log"
7 | "os"
8 | "fmt"
9 | "runtime"
10 | )
11 |
12 | var (
13 | InfoLogger *log.Logger
14 | DebugLogger *log.Logger
15 | IsDebug bool
16 | )
17 |
18 | func InitLogger(debug bool) {
19 | IsDebug = debug
20 | InfoLogger = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime)
21 | if debug {
22 | DebugLogger = log.New(os.Stdout, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile)
23 | } else {
24 | DebugLogger = log.New(io.Discard, "", 0)
25 | }
26 | }
27 |
28 | func Debug(format string, v ...interface{}) {
29 | _, file, line, _ := runtime.Caller(1)
30 | prefix := fmt.Sprintf("DEBUG: [%s:%d] ", file, line)
31 | DebugLogger.SetPrefix(prefix)
32 | DebugLogger.Printf(format, v...)
33 | }
34 |
35 | func Info(format string, v ...interface{}) {
36 | InfoLogger.Printf(format, v...)
37 | }
--------------------------------------------------------------------------------
/internal/player.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net"
7 | "runtime"
8 | )
9 |
10 | func MPVSendCommand(ipcSocketPath string, command []interface{}) (interface{}, error) {
11 | var conn net.Conn
12 | var err error
13 |
14 | if runtime.GOOS == "windows" {
15 | // Use named pipe for Windows
16 | // conn, err = winio.DialPipe(ipcSocketPath, nil)
17 | } else {
18 | conn, err = net.Dial("unix", ipcSocketPath)
19 | }
20 | if err != nil {
21 | return nil, err
22 | }
23 | defer conn.Close()
24 |
25 | commandStr, err := json.Marshal(map[string]interface{}{
26 | "command": command,
27 | })
28 | if err != nil {
29 | return nil, err
30 | }
31 |
32 | // Send the command
33 | _, err = conn.Write(append(commandStr, '\n'))
34 | if err != nil {
35 | return nil, err
36 | }
37 |
38 | // Receive the response
39 | buf := make([]byte, 4096)
40 | n, err := conn.Read(buf)
41 | if err != nil {
42 | return nil, err
43 | }
44 |
45 | var response map[string]interface{}
46 | if err := json.Unmarshal(buf[:n], &response); err != nil {
47 | return nil, err
48 | }
49 |
50 | if data, exists := response["data"]; exists {
51 | return data, nil
52 | }
53 |
54 | return nil, nil
55 | }
56 |
57 | func SeekMPV(ipcSocketPath string, time int) (interface{}, error) {
58 | command := []interface{}{"seek", time, "absolute"}
59 | return MPVSendCommand(ipcSocketPath, command)
60 | }
61 |
62 | func GetMPVPausedStatus(ipcSocketPath string) (bool, error) {
63 | status, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "pause"})
64 | if err != nil || status == nil {
65 | return false, err
66 | }
67 |
68 | paused, ok := status.(bool)
69 | if ok {
70 | return paused, nil
71 | }
72 | return false, nil
73 | }
74 |
75 | func GetMPVPlaybackSpeed(ipcSocketPath string) (float64, error) {
76 | speed, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "speed"})
77 | if err != nil || speed == nil {
78 | return 0, err
79 | }
80 |
81 | currentSpeed, ok := speed.(float64)
82 | if ok {
83 | return currentSpeed, nil
84 | }
85 |
86 | return 0, nil
87 | }
88 |
89 | func GetPercentageWatched(ipcSocketPath string) (float64, error) {
90 | currentTime, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "time-pos"})
91 | if err != nil || currentTime == nil {
92 | return 0, err
93 | }
94 |
95 | duration, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "duration"})
96 | if err != nil || duration == nil {
97 | return 0, err
98 | }
99 |
100 | currTime, ok1 := currentTime.(float64)
101 | dur, ok2 := duration.(float64)
102 |
103 | if ok1 && ok2 && dur > 0 {
104 | percentageWatched := (currTime / dur) * 100
105 | return percentageWatched, nil
106 | }
107 |
108 | return 0, nil
109 | }
110 |
111 | func PercentageWatched(playbackTime int, duration int) float64 {
112 | if duration > 0 {
113 | percentage := (float64(playbackTime) / float64(duration)) * 100
114 | return percentage
115 | }
116 | return float64(0)
117 | }
118 |
119 | // GetMPVPosition gets the current playback position
120 | func GetMPVPosition(socketPath string) (float64, error) {
121 | return getMPVProperty(socketPath, "time-pos")
122 | }
123 |
124 | // GetMPVDuration gets the total duration of the current file
125 | func GetMPVDuration(socketPath string) (float64, error) {
126 | return getMPVProperty(socketPath, "duration")
127 | }
128 |
129 | // StopMPV stops the current playback
130 | func StopMPV(socketPath string) error {
131 | conn, err := net.Dial("unix", socketPath)
132 | if err != nil {
133 | return err
134 | }
135 | defer conn.Close()
136 |
137 | cmd := struct {
138 | Command []string `json:"command"`
139 | RequestID int `json:"request_id"`
140 | }{
141 | Command: []string{"quit"},
142 | RequestID: 1,
143 | }
144 |
145 | return json.NewEncoder(conn).Encode(cmd)
146 | }
147 |
148 | // getMPVProperty is a helper function to get MPV properties
149 | func getMPVProperty(socketPath string, property string) (float64, error) {
150 | conn, err := net.Dial("unix", socketPath)
151 | if err != nil {
152 | return 0, err
153 | }
154 | defer conn.Close()
155 |
156 | cmd := struct {
157 | Command []string `json:"command"`
158 | RequestID int `json:"request_id"`
159 | }{
160 | Command: []string{"get_property", property},
161 | RequestID: 1,
162 | }
163 |
164 | if err := json.NewEncoder(conn).Encode(cmd); err != nil {
165 | return 0, err
166 | }
167 |
168 | var response struct {
169 | Data float64 `json:"data"`
170 | Error string `json:"error"`
171 | RequestID int `json:"request_id"`
172 | }
173 |
174 | if err := json.NewDecoder(conn).Decode(&response); err != nil {
175 | return 0, err
176 | }
177 |
178 | if response.Error != "" {
179 | return 0, fmt.Errorf("mpv error: %s", response.Error)
180 | }
181 |
182 | return response.Data, nil
183 | }
184 |
--------------------------------------------------------------------------------
/internal/rofi.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "path/filepath"
9 | "sort"
10 | "strconv"
11 | "strings"
12 | )
13 |
14 | func RofiSelect(options map[string]string, addShowOpt bool) (SelectionOption, error) {
15 | config := GetGlobalConfig()
16 | if config.StoragePath == "" {
17 | config.StoragePath = os.ExpandEnv("${HOME}/.local/share/buttercup")
18 | }
19 |
20 | // Create a slice to store options with their seeders
21 | type optionWithSeeders struct {
22 | value string
23 | key string
24 | seeders int
25 | }
26 | var optionsList []optionWithSeeders
27 |
28 | // Parse options and extract seeder information
29 | for key, value := range options {
30 | parts := strings.Split(value, "|")
31 | seeders := 0
32 | label := parts[0]
33 |
34 | if len(parts) > 1 {
35 | seeders, _ = strconv.Atoi(parts[1])
36 | }
37 |
38 | optionsList = append(optionsList, optionWithSeeders{
39 | value: label,
40 | key: key,
41 | seeders: seeders,
42 | })
43 | }
44 |
45 | // Sort by seeders in descending order
46 | sort.Slice(optionsList, func(i, j int) bool {
47 | return optionsList[i].seeders > optionsList[j].seeders
48 | })
49 |
50 | // Create the final sorted list
51 | var sortedOptions []string
52 | for _, opt := range optionsList {
53 | sortedOptions = append(sortedOptions, opt.value)
54 | }
55 |
56 | // Add quit option
57 | sortedOptions = append(sortedOptions, "Quit")
58 |
59 | // Join all options into a single string, separated by newlines
60 | optionsString := strings.Join(sortedOptions, "\n")
61 |
62 | // Prepare the Rofi command
63 | cmd := exec.Command("rofi", "-dmenu", "-theme", filepath.Join(os.ExpandEnv(config.StoragePath), "select.rasi"), "-i", "-p", "Select a show")
64 |
65 | // Set up pipes for input and output
66 | cmd.Stdin = strings.NewReader(optionsString)
67 | var out bytes.Buffer
68 | cmd.Stdout = &out
69 |
70 | // Run the command
71 | err := cmd.Run()
72 | if err != nil {
73 | return SelectionOption{}, fmt.Errorf("failed to run Rofi: %v", err)
74 | }
75 |
76 | // Get the selected option
77 | selected := strings.TrimSpace(out.String())
78 |
79 | // Handle special cases
80 | switch selected {
81 | case "":
82 | return SelectionOption{}, fmt.Errorf("no selection made")
83 | case "Quit":
84 | Exit("Have a great day!", nil)
85 | }
86 |
87 | // Find the key for the selected value
88 | for _, opt := range optionsList {
89 | if opt.value == selected {
90 | return SelectionOption{Label: selected, Key: opt.key}, nil
91 | }
92 | }
93 |
94 | return SelectionOption{}, fmt.Errorf("selected option not found in original list")
95 | }
96 |
97 |
98 | // GetUserInputFromRofi prompts the user for input using Rofi with a custom message
99 | func GetUserInputFromRofi(message string) (string, error) {
100 | config := GetGlobalConfig()
101 | if config.StoragePath == "" {
102 | config.StoragePath = os.ExpandEnv("${HOME}/.local/share/buttercup")
103 | }
104 | // Create the Rofi command
105 | cmd := exec.Command("rofi", "-dmenu", "-theme", filepath.Join(os.ExpandEnv(config.StoragePath), "userInput.rasi"), "-p", "Input", "-mesg", message)
106 |
107 | Debug("Rofi command: %v", cmd.String())
108 |
109 | // Set up pipes for output
110 | var out bytes.Buffer
111 | cmd.Stdout = &out
112 |
113 | // Run the command
114 | err := cmd.Run()
115 | if err != nil {
116 | return "", fmt.Errorf("failed to run Rofi: %w", err)
117 | }
118 |
119 | // Get the entered input
120 | userInput := strings.TrimSpace(out.String())
121 |
122 | return userInput, nil
123 | }
--------------------------------------------------------------------------------
/internal/selectionMenu.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 | "strings"
7 | "strconv"
8 | "github.com/charmbracelet/bubbletea"
9 | )
10 |
11 | // SelectionOption holds the label and the internal key
12 | type SelectionOption struct {
13 | Label string
14 | Key string
15 | Seeders int // Add seeder count
16 | URI string // Add tracker URI
17 | }
18 |
19 | // Model represents the application state for the selection prompt
20 | type Model struct {
21 | options map[string]string // id -> name mapping
22 | filter string
23 | filteredKeys []SelectionOption
24 | selected int
25 | terminalWidth int
26 | terminalHeight int
27 | scrollOffset int
28 | }
29 |
30 | // Init initializes the model
31 | func (m Model) Init() tea.Cmd {
32 | return nil
33 | }
34 |
35 | // Update handles user input and updates the model
36 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
37 | if wsm, ok := msg.(tea.WindowSizeMsg); ok {
38 | m.terminalWidth = wsm.Width
39 | m.terminalHeight = wsm.Height
40 | }
41 |
42 | updateFilter := false
43 |
44 | switch msg := msg.(type) {
45 | case tea.KeyMsg:
46 | switch msg.String() {
47 | case "ctrl+c", "q":
48 | m.filteredKeys[m.selected] = SelectionOption{Label: "quit", Key: "-1"}
49 | return m, tea.Quit
50 | case "enter":
51 | return m, tea.Quit
52 | case "backspace":
53 | if len(m.filter) > 0 {
54 | m.filter = m.filter[:len(m.filter)-1]
55 | updateFilter = true
56 | }
57 | case "down":
58 | if m.selected < len(m.filteredKeys)-1 {
59 | m.selected++
60 | }
61 | if m.selected >= m.scrollOffset+m.visibleItemsCount() {
62 | m.scrollOffset++
63 | }
64 | case "up":
65 | if m.selected > 0 {
66 | m.selected--
67 | }
68 | if m.selected < m.scrollOffset {
69 | m.scrollOffset--
70 | }
71 | default:
72 | if len(msg.String()) == 1 && msg.String() >= " " && msg.String() <= "~" {
73 | m.filter += msg.String()
74 | updateFilter = true
75 | }
76 | }
77 | }
78 |
79 | if updateFilter {
80 | m.filterOptions()
81 | m.selected = 0
82 | m.scrollOffset = 0
83 | }
84 |
85 | return m, nil
86 | }
87 |
88 | // View renders the UI
89 | func (m Model) View() string {
90 | var b strings.Builder
91 |
92 | b.WriteString("Search (Press Ctrl+C to quit):\n")
93 | b.WriteString("Filter: " + m.filter + "\n\n")
94 |
95 | if len(m.filteredKeys) == 0 {
96 | b.WriteString("No matches found.\n")
97 | } else {
98 | visibleItems := m.visibleItemsCount()
99 | start := m.scrollOffset
100 | end := start + visibleItems
101 | if end > len(m.filteredKeys) {
102 | end = len(m.filteredKeys)
103 | }
104 |
105 | for i := start; i < end; i++ {
106 | entry := m.filteredKeys[i]
107 | prefix := " "
108 | if i == m.selected {
109 | prefix = "▶ "
110 | }
111 |
112 | // Display format: Name [URI] Seeders
113 | if entry.Key != "-1" { // Not quit option
114 | b.WriteString(fmt.Sprintf("%s%s [%s] %d\n",
115 | prefix, entry.Label, entry.URI, entry.Seeders))
116 | } else {
117 | b.WriteString(fmt.Sprintf("%s%s\n", prefix, entry.Label))
118 | }
119 | }
120 | }
121 |
122 | return b.String()
123 | }
124 |
125 | func (m Model) visibleItemsCount() int {
126 | return m.terminalHeight - 4
127 | }
128 |
129 | func (m *Model) filterOptions() {
130 | m.filteredKeys = []SelectionOption{}
131 |
132 | // First collect all matching items
133 | for id, name := range m.options {
134 | parts := strings.Split(name, "|")
135 | seeders := 0
136 | uri := ""
137 | label := parts[0]
138 |
139 | if len(parts) > 1 {
140 | seeders, _ = strconv.Atoi(parts[1])
141 | }
142 | if len(parts) > 2 {
143 | uri = parts[2]
144 | }
145 |
146 | if strings.Contains(strings.ToLower(label), strings.ToLower(m.filter)) {
147 | m.filteredKeys = append(m.filteredKeys, SelectionOption{
148 | Key: id,
149 | Label: label,
150 | Seeders: seeders,
151 | URI: uri,
152 | })
153 | }
154 | }
155 |
156 | // Sort filteredKeys by seeders in descending order
157 | sort.Slice(m.filteredKeys, func(i, j int) bool {
158 | return m.filteredKeys[i].Seeders > m.filteredKeys[j].Seeders
159 | })
160 | }
161 |
162 | // DynamicSelect shows a selection menu and returns the selected option
163 | func DynamicSelect(options map[string]string) (SelectionOption, error) {
164 | config := GetGlobalConfig()
165 | if config != nil && config.RofiSelection {
166 | return RofiSelect(options, false)
167 | }
168 | model := &Model{
169 | options: options,
170 | filteredKeys: make([]SelectionOption, 0),
171 | }
172 |
173 | model.filterOptions()
174 | p := tea.NewProgram(model)
175 |
176 | finalModel, err := p.Run()
177 | if err != nil {
178 | return SelectionOption{}, err
179 | }
180 |
181 | finalSelectionModel, ok := finalModel.(*Model)
182 | if !ok {
183 | return SelectionOption{}, fmt.Errorf("unexpected model type")
184 | }
185 |
186 | if finalSelectionModel.selected < len(finalSelectionModel.filteredKeys) {
187 | selected := finalSelectionModel.filteredKeys[finalSelectionModel.selected]
188 | return selected, nil
189 | }
190 | return SelectionOption{}, nil
191 | }
192 |
--------------------------------------------------------------------------------
/internal/structs.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | // Create these in a new file like models.go or types.go
4 |
5 | type JackettResponse struct {
6 | Results []Release `json:"Results"`
7 | Indexers []Indexer `json:"Indexers"`
8 | }
9 |
10 | type Release struct {
11 | Title string `json:"Title"`
12 | Guid string `json:"Guid"`
13 | Size int64 `json:"Size"`
14 | PublishDate string `json:"PublishDate"`
15 | Category []int `json:"Category"`
16 | Description string `json:"Description"`
17 | InfoHash string `json:"InfoHash"`
18 | MagnetUri string `json:"MagnetUri"`
19 | Seeders int `json:"Seeders"`
20 | Peers int `json:"Peers"`
21 | TrackerType string `json:"TrackerType"`
22 | Tracker string `json:"Tracker"`
23 | CategoryDesc string `json:"CategoryDesc"`
24 | Files int `json:"Files"`
25 | Genres []string `json:"Genres,omitempty"`
26 | Languages []string `json:"Languages"`
27 | }
28 |
29 | type Indexer struct {
30 | ID string `json:"ID"`
31 | Name string `json:"Name"`
32 | Status int `json:"Status"`
33 | Results int `json:"Results"`
34 | Error string `json:"Error,omitempty"`
35 | ElapsedTime int `json:"ElapsedTime"`
36 | }
37 |
38 | type TorrentFileInfo struct {
39 | DisplayName string
40 | ActualIndex int
41 | }
42 |
43 | type Torrent struct {
44 | Title string
45 | URI string
46 | Size int64
47 | Seeders int
48 | Leechers int
49 | Files []TorrentFileInfo
50 | FileIndex int
51 | SortedFiles []string
52 | }
53 |
54 | type User struct {
55 | Watching Torrent
56 | Player Player
57 | Resume bool
58 | }
59 |
60 | type Player struct {
61 | SocketPath string
62 | PlaybackTime int
63 | Started bool
64 | Duration int
65 | Speed float64
66 | }
67 |
68 |
--------------------------------------------------------------------------------
/internal/torrentStreamer.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net"
8 | "os"
9 | "os/exec"
10 | "path/filepath"
11 | "regexp"
12 | "sort"
13 | "strconv"
14 | "strings"
15 | "syscall"
16 | "time"
17 | "net/url"
18 |
19 | "github.com/anacrolix/torrent"
20 | "github.com/anacrolix/torrent/storage"
21 | "github.com/dustin/go-humanize"
22 | )
23 |
24 | var currentWebtorrentProcess *os.Process
25 |
26 | type TorrentFile struct {
27 | Path string
28 | Size int64
29 | Index int
30 | Priority int
31 | }
32 |
33 | type PrioritizedPiece struct {
34 | Index int
35 | Priority int
36 | }
37 |
38 | type StreamManager struct {
39 | reader *torrent.Reader
40 | turntor *torrent.Torrent
41 | pieceLength int64
42 | currentPos int64
43 | }
44 |
45 | func NewStreamManager(reader *torrent.Reader, t *torrent.Torrent) *StreamManager {
46 | return &StreamManager{
47 | reader: reader,
48 | turntor: t,
49 | pieceLength: t.Info().PieceLength,
50 | currentPos: 0,
51 | }
52 | }
53 |
54 | func (sm *StreamManager) prioritizeFromPosition(pos int64) {
55 | startPiece := pos / sm.pieceLength
56 |
57 | // Reset all piece priorities
58 | for i := 0; i < sm.turntor.NumPieces(); i++ {
59 | sm.turntor.Piece(i).SetPriority(torrent.PiecePriorityNone)
60 | }
61 |
62 | // Only prioritize next 5 seconds worth of pieces
63 | endPiece := startPiece + 5 // Approximate 5 pieces for 5 seconds
64 | fmt.Printf("\nPrioritizing pieces %d to %d\n", startPiece, endPiece)
65 |
66 | for i := startPiece; i < endPiece && i < int64(sm.turntor.NumPieces()); i++ {
67 | piece := sm.turntor.Piece(int(i))
68 | piece.SetPriority(torrent.PiecePriorityNow)
69 |
70 | // Print piece status
71 | fmt.Printf("Piece %d: Complete=%v, Priority=Now\n",
72 | i,
73 | piece.State().Complete)
74 | }
75 | }
76 |
77 | // Add this function to get MPV position
78 | func getMPVPosition(socketPath string) (float64, error) {
79 | conn, err := net.Dial("unix", socketPath)
80 | if err != nil {
81 | return 0, err
82 | }
83 | defer conn.Close()
84 |
85 | // Send get_property command
86 | cmd := struct {
87 | Command []string `json:"command"`
88 | RequestID int `json:"request_id"`
89 | }{
90 | Command: []string{"get_property", "time-pos"},
91 | RequestID: 1,
92 | }
93 |
94 | if err := json.NewEncoder(conn).Encode(cmd); err != nil {
95 | return 0, err
96 | }
97 |
98 | // Read response
99 | var response struct {
100 | Data float64 `json:"data"`
101 | Error string `json:"error"`
102 | RequestID int `json:"request_id"`
103 | }
104 |
105 | if err := json.NewDecoder(conn).Decode(&response); err != nil {
106 | return 0, err
107 | }
108 |
109 | if response.Error != "" {
110 | return 0, fmt.Errorf("mpv error: %s", response.Error)
111 | }
112 |
113 | return response.Data, nil
114 | }
115 |
116 |
117 | func StartWebtorrentServer(magnetURI string, selectedIndex int) error {
118 | // First cleanup any existing webtorrent processes
119 | CleanupWebtorrent()
120 |
121 | // Get storage path from config
122 | config := GetGlobalConfig()
123 |
124 | config.StoragePath = os.ExpandEnv(config.StoragePath)
125 |
126 | // Create storage directory if it doesn't exist
127 | if err := os.MkdirAll(config.StoragePath, 0755); err != nil {
128 | return fmt.Errorf("failed to create storage directory: %w", err)
129 | }
130 |
131 | // Create the webtorrent command
132 | webtorrentCmd := exec.Command("webtorrent",
133 | magnetURI,
134 | "--select", fmt.Sprintf("%d", selectedIndex),
135 | "--keep-seeding",
136 | "--no-quit",
137 | "--quiet",
138 | "--port", "8000",
139 | "--out", config.StoragePath,
140 | )
141 |
142 | // Setup pipes for stdout/stderr
143 | webtorrentCmd.Stdout = os.Stdout
144 | webtorrentCmd.Stderr = os.Stderr
145 |
146 | // Start the process
147 | if err := webtorrentCmd.Start(); err != nil {
148 | return fmt.Errorf("failed to start webtorrent: %w", err)
149 | }
150 |
151 | // Store the process for cleanup
152 | currentWebtorrentProcess = webtorrentCmd.Process
153 |
154 | // Don't wait for it to complete
155 | go func() {
156 | webtorrentCmd.Wait()
157 | }()
158 |
159 | Debug("Started webtorrent process (PID: %d) with storage path: %s",
160 | currentWebtorrentProcess.Pid,
161 | config.StoragePath)
162 | return nil
163 | }
164 |
165 |
166 | func GetTorrentFiles(magnetURI string) ([]TorrentFileInfo, error) {
167 | // Create temporary directory for downloads
168 | tmpDir, err := os.MkdirTemp("", "torrent-stream-*")
169 | if err != nil {
170 | return nil, fmt.Errorf("failed to create temp dir: %w", err)
171 | }
172 | defer os.RemoveAll(tmpDir)
173 |
174 | // Configure torrent client
175 | cfg := torrent.NewDefaultClientConfig()
176 | cfg.DataDir = tmpDir
177 | cfg.DefaultStorage = storage.NewFile(tmpDir)
178 |
179 | // Create torrent client
180 | client, err := torrent.NewClient(cfg)
181 | if err != nil {
182 | return nil, fmt.Errorf("failed to create torrent client: %w", err)
183 | }
184 | defer client.Close()
185 |
186 | // Add the magnet link
187 | t, err := client.AddMagnet(magnetURI)
188 | if err != nil {
189 | return nil, fmt.Errorf("failed to add magnet: %w", err)
190 | }
191 |
192 | // Wait for torrent info
193 | <-t.GotInfo()
194 |
195 | // Create list of video files with their actual indices
196 | files := make([]TorrentFileInfo, 0)
197 | for i, file := range t.Files() {
198 | if IsVideoFile(file.Path()) {
199 | files = append(files, TorrentFileInfo{
200 | DisplayName: fmt.Sprintf("%s (%s)",
201 | file.Path(),
202 | humanize.Bytes(uint64(file.Length()))),
203 | ActualIndex: i,
204 | })
205 | }
206 | }
207 |
208 | if len(files) == 0 {
209 | return nil, fmt.Errorf("no video files found in torrent")
210 | }
211 |
212 | return files, nil
213 | }
214 |
215 | func GetWebtorrentStreamURL(magnetURI string, selectedIndex int) (string, error) {
216 | // Create temporary directory for downloads
217 | tmpDir, err := os.MkdirTemp("", "torrent-stream-*")
218 | if err != nil {
219 | return "", fmt.Errorf("failed to create temp dir: %w", err)
220 | }
221 | defer os.RemoveAll(tmpDir)
222 |
223 | // Configure torrent client
224 | cfg := torrent.NewDefaultClientConfig()
225 | cfg.DataDir = tmpDir
226 | cfg.DefaultStorage = storage.NewFile(tmpDir)
227 |
228 | // Create torrent client
229 | client, err := torrent.NewClient(cfg)
230 | if err != nil {
231 | return "", fmt.Errorf("failed to create torrent client: %w", err)
232 | }
233 | defer client.Close()
234 |
235 | // Add the magnet link
236 | t, err := client.AddMagnet(magnetURI)
237 | if err != nil {
238 | return "", fmt.Errorf("failed to add magnet: %w", err)
239 | }
240 |
241 | // Wait for torrent info
242 | <-t.GotInfo()
243 |
244 | // Get the selected file
245 | selectedFile := t.Files()[selectedIndex]
246 |
247 | // Split the path and encode each component separately
248 | pathComponents := strings.Split(selectedFile.Path(), "/")
249 | encodedComponents := make([]string, len(pathComponents))
250 | for i, component := range pathComponents {
251 | encodedComponents[i] = url.PathEscape(component)
252 | }
253 |
254 | // Construct the webtorrent URL with properly encoded path components
255 | streamURL := fmt.Sprintf("http://localhost:8000/webtorrent/%x/%s",
256 | t.InfoHash(),
257 | strings.Join(encodedComponents, "/"))
258 |
259 | return streamURL, nil
260 | }
261 |
262 | // Add this helper function to check if a port is in use
263 | func isPortInUse(port int) bool {
264 | addr := fmt.Sprintf(":%d", port)
265 | listener, err := net.Listen("tcp", addr)
266 | if err != nil {
267 | return true
268 | }
269 | listener.Close()
270 | return false
271 | }
272 |
273 | func StreamTorrentWebtorrent(magnetURI string, selectedIndex int) (string, error) {
274 | // Start webtorrent server
275 | err := StartWebtorrentServer(magnetURI, selectedIndex)
276 | if err != nil {
277 | return "", err
278 | }
279 |
280 | // Get the stream URL
281 | streamURL, err := GetWebtorrentStreamURL(magnetURI, selectedIndex)
282 | if err != nil {
283 | return "", err
284 | }
285 |
286 | Debug("Stream URL: %s", streamURL)
287 |
288 | // Create socket path with random component
289 | socketPath := filepath.Join("/tmp", fmt.Sprintf("buttercup-%x.sock", time.Now().UnixNano()))
290 |
291 | // Start MPV once server is ready
292 | mpvCmd := exec.Command("mpv",
293 | "--force-seekable=yes",
294 | "--input-ipc-server="+socketPath,
295 | "--cache=yes",
296 | "--cache-secs=10",
297 | "--demuxer-max-bytes=50M",
298 | "--demuxer-readahead-secs=5",
299 | "--really-quiet",
300 | streamURL,
301 | )
302 |
303 | // Redirect output to /dev/null
304 | mpvCmd.Stdout = nil
305 | mpvCmd.Stderr = nil
306 |
307 | err = mpvCmd.Start()
308 | if err != nil {
309 | return "", fmt.Errorf("failed to start mpv: %w", err)
310 | }
311 |
312 | Debug("Started mpv successfully")
313 | return socketPath, nil
314 | }
315 |
316 | func StreamTorrentSequentially(magnetURI string) error {
317 | // Create temporary directory for downloads
318 | tmpDir, err := os.MkdirTemp("", "torrent-stream-*")
319 | if err != nil {
320 | return fmt.Errorf("failed to create temp dir: %w", err)
321 | }
322 | defer os.RemoveAll(tmpDir)
323 |
324 | // Configure torrent client
325 | cfg := torrent.NewDefaultClientConfig()
326 | cfg.DataDir = tmpDir
327 | cfg.DefaultStorage = storage.NewFile(tmpDir)
328 |
329 | // Create torrent client
330 | client, err := torrent.NewClient(cfg)
331 | if err != nil {
332 | return fmt.Errorf("failed to create torrent client: %w", err)
333 | }
334 | defer client.Close()
335 |
336 | // Add the magnet link
337 | t, err := client.AddMagnet(magnetURI)
338 | if err != nil {
339 | return fmt.Errorf("failed to add magnet: %w", err)
340 | }
341 |
342 | // Wait for torrent info
343 | <-t.GotInfo()
344 |
345 | // Create options map for selection menu
346 | options := make(map[string]string)
347 | for i, file := range t.Files() {
348 | options[fmt.Sprintf("%d", i)] = fmt.Sprintf("%s (%s)",
349 | file.Path(),
350 | humanize.Bytes(uint64(file.Length())))
351 | }
352 |
353 | // Use our existing selection menu
354 | selected, err := DynamicSelect(options)
355 | if err != nil {
356 | return fmt.Errorf("selection error: %w", err)
357 | }
358 |
359 | if selected.Key == "-1" {
360 | return fmt.Errorf("selection cancelled")
361 | }
362 |
363 | // Convert selected key to index
364 | selectedIndex, _ := strconv.Atoi(selected.Key)
365 | selectedFile := t.Files()[selectedIndex]
366 |
367 | // Create a reader for the selected file
368 | reader := selectedFile.NewReader()
369 | reader.SetResponsive() // Enable seeking
370 | defer reader.Close()
371 |
372 | streamManager := NewStreamManager(&reader, t)
373 | streamManager.prioritizeFromPosition(0)
374 |
375 | // Create named pipe for MPV
376 | pipePath := filepath.Join(tmpDir, "stream.pipe")
377 | err = syscall.Mkfifo(pipePath, 0600)
378 | if err != nil {
379 | return fmt.Errorf("failed to create named pipe: %w", err)
380 | }
381 |
382 | mpvPath, err := exec.LookPath("mpv")
383 | if err != nil {
384 | return fmt.Errorf("mpv not found: %w", err)
385 | }
386 |
387 | // Create MPV socket path
388 | socketPath := filepath.Join(tmpDir, "mpvsocket")
389 |
390 | // Start MPV with socket
391 | cmd := exec.Command(mpvPath,
392 | "--force-seekable=yes",
393 | "--input-ipc-server="+socketPath,
394 | "--cache=yes", // Enable cache
395 | "--cache-secs=10", // Cache 10 seconds
396 | "--demuxer-max-bytes=50M", // Allow larger forward cache
397 | "--demuxer-readahead-secs=5", // Read 5 seconds ahead
398 | pipePath)
399 |
400 | cmd.Stdout = os.Stdout
401 | cmd.Stderr = os.Stderr
402 |
403 | err = cmd.Start()
404 | if err != nil {
405 | return fmt.Errorf("failed to start mpv: %w", err)
406 | }
407 |
408 | // Open pipe for writing
409 | pipe, err := os.OpenFile(pipePath, os.O_WRONLY, 0600)
410 | if err != nil {
411 | cmd.Process.Kill()
412 | return fmt.Errorf("failed to open pipe: %w", err)
413 | }
414 | defer pipe.Close()
415 |
416 | // Start position monitoring goroutine using MPV socket
417 | done := make(chan struct{})
418 | go func() {
419 | defer close(done)
420 | var lastPos float64 = -1
421 |
422 | // Wait for socket to be created
423 | time.Sleep(time.Second)
424 |
425 | for {
426 | select {
427 | case <-done:
428 | return
429 | default:
430 | pos, err := getMPVPosition(socketPath)
431 | if err != nil {
432 | time.Sleep(time.Second)
433 | continue
434 | }
435 |
436 | if pos != lastPos {
437 | fmt.Printf("\nPlayback position changed to: %.2f seconds\n", pos)
438 | fmt.Printf("File length: %d bytes, Video length: %d seconds\n",
439 | selectedFile.Length(),
440 | t.Info().Length)
441 |
442 | // Convert seconds to bytes (approximate)
443 | bytesPerSecond := float64(selectedFile.Length()) / float64(t.Info().Length)
444 | bytePos := int64(pos * bytesPerSecond)
445 | fmt.Printf("Estimated byte position: %d\n", bytePos)
446 |
447 | streamManager.prioritizeFromPosition(bytePos)
448 | lastPos = pos
449 | }
450 |
451 | time.Sleep(100 * time.Millisecond)
452 | }
453 | }
454 | }()
455 |
456 | // Simple copy loop with better error handling
457 | buf := make([]byte, 64*1024) // 64KB buffer
458 | for {
459 | n, err := reader.Read(buf)
460 | if err == io.EOF {
461 | break
462 | }
463 | if err != nil {
464 | if err == io.ErrUnexpectedEOF {
465 | // This might happen during seeking, wait a bit and continue
466 | time.Sleep(100 * time.Millisecond)
467 | continue
468 | }
469 | cmd.Process.Kill()
470 | done <- struct{}{}
471 | return fmt.Errorf("read error: %w", err)
472 | }
473 |
474 | _, err = pipe.Write(buf[:n])
475 | if err != nil {
476 | if err == syscall.EPIPE {
477 | // MPV was closed
478 | break
479 | }
480 | cmd.Process.Kill()
481 | done <- struct{}{}
482 | return fmt.Errorf("write error: %w", err)
483 | }
484 | }
485 |
486 | done <- struct{}{}
487 | <-done
488 | return cmd.Wait()
489 | }
490 |
491 | func FindAndSortEpisodes(files []string) []string {
492 | type Episode struct {
493 | Path string
494 | Season int
495 | Episode int
496 | }
497 |
498 | var episodes []Episode
499 |
500 | // Regular expression to match common episode patterns
501 | // Matches: s01e01, s1e1, 1x01, etc.
502 | seasonEpRegex := regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,2})|(\d{1,2})x(\d{1,2})`)
503 |
504 | for _, file := range files {
505 | matches := seasonEpRegex.FindStringSubmatch(strings.ToLower(file))
506 | if matches != nil {
507 | var season, episode int
508 | if matches[1] != "" {
509 | // s01e01 format
510 | season, _ = strconv.Atoi(matches[1])
511 | episode, _ = strconv.Atoi(matches[2])
512 | } else {
513 | // 1x01 format
514 | season, _ = strconv.Atoi(matches[3])
515 | episode, _ = strconv.Atoi(matches[4])
516 | }
517 | episodes = append(episodes, Episode{
518 | Path: file,
519 | Season: season,
520 | Episode: episode,
521 | })
522 | }
523 | }
524 |
525 | // Sort episodes by season and episode number
526 | sort.Slice(episodes, func(i, j int) bool {
527 | if episodes[i].Season != episodes[j].Season {
528 | return episodes[i].Season < episodes[j].Season
529 | }
530 | return episodes[i].Episode < episodes[j].Episode
531 | })
532 |
533 | // Convert back to sorted paths
534 | sortedFiles := make([]string, len(episodes))
535 | for i, ep := range episodes {
536 | sortedFiles[i] = ep.Path
537 | }
538 |
539 | return sortedFiles
540 | }
541 |
542 | // Update the cleanup function to be more thorough
543 | func CleanupWebtorrent() {
544 | // Kill any existing webtorrent processes
545 | exec.Command("pkill", "-f", "webtorrent").Run()
546 |
547 | // Also kill any process using our ports
548 | exec.Command("fuser", "-k", "8000/tcp").Run()
549 | exec.Command("fuser", "-k", "42069/tcp").Run()
550 |
551 | // If we have a stored process, kill it directly
552 | if currentWebtorrentProcess != nil {
553 | currentWebtorrentProcess.Kill()
554 | currentWebtorrentProcess = nil
555 | }
556 |
557 | // Give it a moment to clean up
558 | time.Sleep(500 * time.Millisecond)
559 | }
560 |
561 |
--------------------------------------------------------------------------------
/internal/torrentTracking.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "encoding/csv"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strconv"
9 | )
10 |
11 | // TorrentData represents the structure for storing torrent playback information
12 | type TorrentData struct {
13 | MagnetURI string
14 | FileName string
15 | FileIndex int
16 | PlaybackTime int
17 | Title string
18 | }
19 |
20 | // Function to add a torrent entry
21 | func LocalAddTorrent(databaseFile string, magnetURI string, fileIndex int, playbackTime int, title string) {
22 | // Read existing entries first
23 | torrentList := LocalGetAllTorrents(databaseFile)
24 |
25 | // Check if magnet URI already exists
26 | for _, torrent := range torrentList {
27 | if torrent.MagnetURI == magnetURI {
28 | // Update existing entry instead of creating new one
29 | err := LocalUpdateTorrent(databaseFile, magnetURI, fileIndex, playbackTime, title)
30 | if err != nil {
31 | Output(fmt.Sprintf("Error updating existing torrent: %v", err))
32 | }
33 | return
34 | }
35 | }
36 |
37 | // If we get here, it's a new magnet URI
38 | file, err := os.OpenFile(databaseFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
39 | if err != nil {
40 | Output(fmt.Sprintf("Error opening file: %v", err))
41 | return
42 | }
43 | defer file.Close()
44 |
45 | writer := csv.NewWriter(file)
46 | writer.Comma = '|'
47 | defer writer.Flush()
48 |
49 | err = writer.Write([]string{
50 | magnetURI,
51 | strconv.Itoa(fileIndex),
52 | strconv.Itoa(playbackTime),
53 | title,
54 | })
55 | if err != nil {
56 | Output(fmt.Sprintf("Error writing to file: %v", err))
57 | } else {
58 | Output("Written to file")
59 | }
60 | }
61 |
62 | // Function to get all torrent entries
63 | func LocalGetAllTorrents(databaseFile string) []TorrentData {
64 | torrentList := []TorrentData{}
65 |
66 | // Ensure the directory exists
67 | dir := filepath.Dir(databaseFile)
68 | if err := os.MkdirAll(dir, 0755); err != nil {
69 | Output(fmt.Sprintf("Error creating directory: %v", err))
70 | return torrentList
71 | }
72 |
73 | // Open the file, create if it doesn't exist
74 | file, err := os.OpenFile(databaseFile, os.O_RDONLY|os.O_CREATE, 0644)
75 | if err != nil {
76 | Output(fmt.Sprintf("Error opening or creating file: %v", err))
77 | return torrentList
78 | }
79 | defer file.Close()
80 |
81 | // If the file was just created, return empty list
82 | fileInfo, err := file.Stat()
83 | if err != nil {
84 | Output(fmt.Sprintf("Error getting file info: %v", err))
85 | return torrentList
86 | }
87 | if fileInfo.Size() == 0 {
88 | return torrentList
89 | }
90 |
91 | reader := csv.NewReader(file)
92 | reader.Comma = '|'
93 | reader.FieldsPerRecord = 4
94 |
95 | records, err := reader.ReadAll()
96 | if err != nil {
97 | Output(fmt.Sprintf("Error reading file: %v", err))
98 | return torrentList
99 | }
100 |
101 | for _, row := range records {
102 | t := parseTorrentRow(row)
103 | if t != nil {
104 | torrentList = append(torrentList, *t)
105 | }
106 | }
107 |
108 | return torrentList
109 | }
110 |
111 | // Function to parse a single row of torrent data
112 | func parseTorrentRow(row []string) *TorrentData {
113 | if len(row) < 4 {
114 | Output(fmt.Sprintf("Invalid row format: %v", row))
115 | return nil
116 | }
117 |
118 | fileIndex, _ := strconv.Atoi(row[1])
119 | playbackTime, _ := strconv.Atoi(row[2])
120 |
121 | return &TorrentData{
122 | MagnetURI: row[0],
123 | FileIndex: fileIndex,
124 | PlaybackTime: playbackTime,
125 | Title: row[3],
126 | }
127 | }
128 |
129 | // Function to update or add a torrent entry
130 | func LocalUpdateTorrent(databaseFile string, magnetURI string, fileIndex int, playbackTime int, title string) error {
131 | // Read existing entries
132 | torrentList := LocalGetAllTorrents(databaseFile)
133 |
134 | // Find and update existing entry or add new one
135 | updated := false
136 | for i, torrent := range torrentList {
137 | if torrent.MagnetURI == magnetURI {
138 | torrentList[i].FileIndex = fileIndex
139 | torrentList[i].PlaybackTime = playbackTime
140 | torrentList[i].Title = title
141 | updated = true
142 | break
143 | }
144 | }
145 |
146 | if !updated {
147 | newTorrent := TorrentData{
148 | MagnetURI: magnetURI,
149 | FileIndex: fileIndex,
150 | PlaybackTime: playbackTime,
151 | Title: title,
152 | }
153 | torrentList = append(torrentList, newTorrent)
154 | }
155 |
156 | // Write updated list back to file
157 | file, err := os.Create(databaseFile)
158 | if err != nil {
159 | return fmt.Errorf("error creating file: %v", err)
160 | }
161 | defer file.Close()
162 |
163 | writer := csv.NewWriter(file)
164 | writer.Comma = '|'
165 | defer writer.Flush()
166 |
167 | for _, torrent := range torrentList {
168 | record := []string{
169 | torrent.MagnetURI,
170 | strconv.Itoa(torrent.FileIndex),
171 | strconv.Itoa(torrent.PlaybackTime),
172 | torrent.Title,
173 | }
174 | if err := writer.Write(record); err != nil {
175 | return fmt.Errorf("error writing record: %v", err)
176 | }
177 | }
178 |
179 | writer.Flush()
180 | return nil
181 | }
182 |
183 | // Function to find a torrent by magnet URI and file index
184 | func LocalFindTorrent(torrentList []TorrentData, magnetURI string, fileIndex int) *TorrentData {
185 | for _, torrent := range torrentList {
186 | if torrent.MagnetURI == magnetURI && torrent.FileIndex == fileIndex {
187 | return &torrent
188 | }
189 | }
190 | return nil
191 | }
--------------------------------------------------------------------------------
/internal/utils.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "net/http"
7 | "os"
8 | "path/filepath"
9 | "runtime"
10 | "strings"
11 | )
12 |
13 | func IsVideoFile(filename string) bool {
14 | videoExtensions := []string{
15 | ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv",
16 | ".webm", ".m4v", ".mpg", ".mpeg", ".3gp",
17 | }
18 |
19 | ext := strings.ToLower(filepath.Ext(filename))
20 | for _, videoExt := range videoExtensions {
21 | if ext == videoExt {
22 | return true
23 | }
24 | }
25 | return false
26 | }
27 |
28 |
29 | func UpdateButtercup(repo, fileName string) error {
30 | // Get the path of the currently running executable
31 | executablePath, err := os.Executable()
32 | if err != nil {
33 | return fmt.Errorf("unable to find current executable: %v", err)
34 | }
35 |
36 | // Adjust file name for Windows
37 | if runtime.GOOS == "windows" {
38 | fileName += ".exe"
39 | }
40 |
41 | // GitHub release URL for buttercup
42 | url := fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", repo, fileName)
43 |
44 | // Temporary path for the downloaded buttercup executable
45 | tmpPath := executablePath + ".tmp"
46 |
47 | // Download the buttercup executable
48 | resp, err := http.Get(url)
49 | if err != nil {
50 | return fmt.Errorf("failed to download file: %v", err)
51 | }
52 | defer resp.Body.Close()
53 |
54 | // Check if the download was successful
55 | if resp.StatusCode != http.StatusOK {
56 | return fmt.Errorf("failed to download file: received status code %d", resp.StatusCode)
57 | }
58 |
59 | // Create a new temporary file
60 | out, err := os.Create(tmpPath)
61 | if err != nil {
62 | return fmt.Errorf("failed to create temporary file: %v", err)
63 | }
64 | defer out.Close()
65 |
66 | // Write the downloaded content to the temporary file
67 | _, err = io.Copy(out, resp.Body)
68 | if err != nil {
69 | return fmt.Errorf("failed to save downloaded file: %v", err)
70 | }
71 |
72 | // Close and rename the temporary file to replace the current executable
73 | out.Close()
74 |
75 | // Replace the current executable with the downloaded buttercup
76 | if err := os.Rename(tmpPath, executablePath); err != nil {
77 | return fmt.Errorf("failed to replace the current executable: %v", err)
78 | }
79 | Exit(fmt.Sprintf("Downloaded buttercup executable to %v", executablePath), nil)
80 |
81 | if runtime.GOOS != "windows" {
82 | // Ensure the new file has executable permissions
83 | if err := os.Chmod(executablePath, 0755); err != nil {
84 | return fmt.Errorf("failed to set permissions on the new file: %v", err)
85 | }
86 | }
87 |
88 | return nil
89 | }
90 |
91 | func CheckAndDownloadFiles(storagePath string, filesToCheck []string) error {
92 | // Create storage directory if it doesn't exist
93 | storagePath = os.ExpandEnv(storagePath)
94 | if err := os.MkdirAll(storagePath, 0755); err != nil {
95 | return fmt.Errorf("failed to create storage directory: %v", err)
96 | }
97 |
98 | // Base URL for downloading config files
99 | baseURL := "https://raw.githubusercontent.com/Wraient/buttercup/main/rofi/"
100 |
101 | // Check each file
102 | for _, fileName := range filesToCheck {
103 | filePath := filepath.Join(os.ExpandEnv(storagePath), fileName)
104 |
105 | // Skip if file already exists
106 | if _, err := os.Stat(filePath); err == nil {
107 | continue
108 | }
109 |
110 | // Download file if it doesn't exist
111 | resp, err := http.Get(baseURL + fileName)
112 | if err != nil {
113 | return fmt.Errorf("failed to download %s: %v", fileName, err)
114 | }
115 | defer resp.Body.Close()
116 |
117 | // Create the file
118 | out, err := os.Create(filePath)
119 | if err != nil {
120 | return fmt.Errorf("failed to create file %s: %v", fileName, err)
121 | }
122 | defer out.Close()
123 |
124 | // Write the content
125 | if _, err := io.Copy(out, resp.Body); err != nil {
126 | return fmt.Errorf("failed to write file %s: %v", fileName, err)
127 | }
128 | }
129 |
130 | return nil
131 | }
--------------------------------------------------------------------------------
/jackett/1337x.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "sitelink",
4 | "type": "inputstring",
5 | "name": "Site Link",
6 | "value": "https://1337x.to/"
7 | },
8 | {
9 | "id": "cookieheader",
10 | "type": "hiddendata",
11 | "name": "CookieHeader",
12 | "value": ""
13 | },
14 | {
15 | "id": "lasterror",
16 | "type": "hiddendata",
17 | "name": "LastError",
18 | "value": null
19 | },
20 | {
21 | "id": "downloadlink",
22 | "type": "inputselect",
23 | "name": "Download link",
24 | "value": "magnet:",
25 | "options": {
26 | "http://itorrents.org/": "iTorrents.org",
27 | "magnet:": "magnet"
28 | }
29 | },
30 | {
31 | "id": "downloadlink(fallback)",
32 | "type": "inputselect",
33 | "name": "Download link (fallback)",
34 | "value": "magnet:",
35 | "options": {
36 | "http://itorrents.org/": "iTorrents.org",
37 | "magnet:": "magnet"
38 | }
39 | },
40 | {
41 | "id": "sortrequestedfromsite",
42 | "type": "inputselect",
43 | "name": "Sort requested from site",
44 | "value": "seeders",
45 | "options": {
46 | "time": "created",
47 | "seeders": "seeders",
48 | "size": "size"
49 | }
50 | },
51 | {
52 | "id": "orderrequestedfromsite",
53 | "type": "inputselect",
54 | "name": "Order requested from site",
55 | "value": "desc",
56 | "options": {
57 | "desc": "desc",
58 | "asc": "asc"
59 | }
60 | },
61 | {
62 | "id": "tags",
63 | "type": "inputtags",
64 | "name": "Tags",
65 | "value": ""
66 | }
67 | ]
--------------------------------------------------------------------------------
/jackett/nyaasi.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "sitelink",
4 | "type": "inputstring",
5 | "name": "Site Link",
6 | "value": "https://nyaa.si/"
7 | },
8 | {
9 | "id": "cookieheader",
10 | "type": "hiddendata",
11 | "name": "CookieHeader",
12 | "value": "__ddg8_=LHeKkLtZmBMCHYQw; __ddg9_=103.132.151.30; __ddg10_=1731071290; __ddg1_=2mElO7IO51aisKe8Mily"
13 | },
14 | {
15 | "id": "lasterror",
16 | "type": "hiddendata",
17 | "name": "LastError",
18 | "value": null
19 | },
20 | {
21 | "id": "prefermagnetlinks",
22 | "type": "inputbool",
23 | "name": "Prefer Magnet Links",
24 | "value": true
25 | },
26 | {
27 | "id": "improvesonarrcompatibilitybytryingtoaddseasoninformationintoreleasetitles",
28 | "type": "inputbool",
29 | "name": "Improve Sonarr compatibility by trying to add Season information into Release Titles",
30 | "value": false
31 | },
32 | {
33 | "id": "removefirstseasonkeywords(s1/s01/season1),assomeresultsdonotincludethisforfirst/singleseasonreleases",
34 | "type": "inputbool",
35 | "name": "Remove first season keywords (S1/S01/Season 1), as some results do not include this for first/single season releases",
36 | "value": false
37 | },
38 | {
39 | "id": "improveradarrcompatibilitybyremovingyearinformationfromkeywordsandaddingittoreleasetitles",
40 | "type": "inputbool",
41 | "name": "Improve Radarr compatibility by removing year information from keywords and adding it to Release Titles",
42 | "value": false
43 | },
44 | {
45 | "id": "filter",
46 | "type": "inputselect",
47 | "name": "Filter",
48 | "value": "0",
49 | "options": {
50 | "0": "No filter",
51 | "1": "No remakes",
52 | "2": "Trusted only"
53 | }
54 | },
55 | {
56 | "id": "category",
57 | "type": "inputselect",
58 | "name": "Category",
59 | "value": "0_0",
60 | "options": {
61 | "0_0": "All categories",
62 | "1_0": "Anime",
63 | "1_1": "Anime - Anime Music Video",
64 | "1_2": "Anime - English-translated",
65 | "1_3": "Anime - Non-English-translated",
66 | "1_4": "Anime - Raw",
67 | "2_0": "Audio",
68 | "2_1": "Audio - Lossless",
69 | "2_2": "Audio - Lossy",
70 | "3_0": "Literature",
71 | "3_1": "Literature - English-translated",
72 | "3_2": "Literature - Non-English-translated",
73 | "3_3": "Literature - Lossy",
74 | "4_0": "Live Action",
75 | "4_1": "Live Action - English",
76 | "4_2": "Live Action - Idol/PV",
77 | "4_3": "Live Action - Non-English",
78 | "4_4": "Live Action - Raw",
79 | "5_0": "Pictures",
80 | "5_1": "Pictures - Graphics",
81 | "5_2": "Pictures - Photos",
82 | "6_0": "Software",
83 | "6_1": "Software - Applications",
84 | "6_2": "Software - Games"
85 | }
86 | },
87 | {
88 | "id": "sortrequestedfromsite",
89 | "type": "inputselect",
90 | "name": "Sort requested from site",
91 | "value": "id",
92 | "options": {
93 | "id": "created",
94 | "seeders": "seeders",
95 | "size": "size"
96 | }
97 | },
98 | {
99 | "id": "orderrequestedfromsite",
100 | "type": "inputselect",
101 | "name": "Order requested from site",
102 | "value": "desc",
103 | "options": {
104 | "desc": "desc",
105 | "asc": "asc"
106 | }
107 | },
108 | {
109 | "id": "tags",
110 | "type": "inputtags",
111 | "name": "Tags",
112 | "value": ""
113 | }
114 | ]
--------------------------------------------------------------------------------
/rofi/select.rasi:
--------------------------------------------------------------------------------
1 | configuration {
2 | font: "Sans 12";
3 | line-margin: 10;
4 | display-drun: "";
5 | }
6 |
7 | * {
8 | background: #000000; /* Black background for everything */
9 | background-alt: #000000; /* Ensures no alternation */
10 | foreground: #CCCCCC;
11 | selected: #3584E4;
12 | active: #2E7D32;
13 | urgent: #C62828;
14 | }
15 |
16 | window {
17 | fullscreen: false;
18 | background-color: rgba(0, 0, 0, 1); /* Solid black background */
19 | }
20 |
21 | mainbox {
22 | padding: 50px 100px;
23 | background-color: rgba(0, 0, 0, 1); /* Ensures black background fills entire main area */
24 | children: [inputbar, listview];
25 | spacing: 20px;
26 | }
27 |
28 | inputbar {
29 | background-color: #333333; /* Dark gray background for input bar */
30 | padding: 8px;
31 | border-radius: 8px;
32 | children: [prompt, entry];
33 | }
34 |
35 | prompt {
36 | enabled: true;
37 | padding: 8px;
38 | background-color: @selected;
39 | text-color: #000000;
40 | border-radius: 4px;
41 | }
42 |
43 | entry {
44 | padding: 8px;
45 | background-color: #444444; /* Slightly lighter gray for visibility */
46 | text-color: #FFFFFF; /* White text to make typing visible */
47 | placeholder: "Search...";
48 | placeholder-color: rgba(255, 255, 255, 0.5);
49 | border-radius: 6px;
50 | }
51 |
52 | listview {
53 | layout: vertical;
54 | spacing: 8px;
55 | lines: 10;
56 | background-color: @background; /* Consistent black background for list items */
57 | }
58 |
59 | element {
60 | padding: 12px;
61 | border-radius: 4px;
62 | background-color: @background; /* Uniform color for each list item */
63 | text-color: @foreground;
64 | }
65 |
66 | element normal.normal {
67 | background-color: @background; /* Ensures no alternating color */
68 | }
69 |
70 | element selected.normal {
71 | background-color: @selected;
72 | text-color: #FFFFFF;
73 | }
74 |
75 | element-text {
76 | background-color: transparent;
77 | text-color: inherit;
78 | vertical-align: 0.5;
79 | }
80 |
--------------------------------------------------------------------------------
/rofi/selectPreview.rasi:
--------------------------------------------------------------------------------
1 | // Colours
2 | * {
3 | background-color: transparent;
4 | background: #1D2330;
5 | background-transparent: #1D2330A0;
6 | text-color: #BBBBBB;
7 | text-color-selected: #FFFFFF;
8 | primary: #BB77BB;
9 | important: #BF616A;
10 | }
11 |
12 | configuration {
13 | font: "Roboto 17";
14 | show-icons: true;
15 | }
16 |
17 | window {
18 | fullscreen: true;
19 | height: 100%;
20 | width: 100%;
21 | transparency: "real";
22 | background-color: @background-transparent;
23 | border: 0px;
24 | border-color: @primary;
25 | }
26 |
27 | mainbox {
28 | children: [prompt, inputbar-box, listview];
29 | padding: 0px;
30 | }
31 |
32 | prompt {
33 | width: 100%;
34 | margin: 10px 0px 0px 30px;
35 | text-color: @important;
36 | font: "Roboto Bold 27";
37 | }
38 |
39 | listview {
40 | layout: vertical;
41 | padding: 60px;
42 | dynamic: true;
43 | columns: 7;
44 | spacing: 20px;
45 | horizontal-align: center; /* Center the list items */
46 | }
47 |
48 | inputbar-box {
49 | children: [dummy, inputbar, dummy];
50 | orientation: horizontal;
51 | expand: false;
52 | }
53 |
54 | inputbar {
55 | children: [textbox-prompt, entry];
56 | margin: 0px;
57 | background-color: @primary;
58 | border: 4px;
59 | border-color: @primary;
60 | border-radius: 8px;
61 | }
62 |
63 | textbox-prompt {
64 | text-color: @background;
65 | horizontal-align: 0.5;
66 | vertical-align: 0.5;
67 | expand: false;
68 | }
69 |
70 | entry {
71 | expand: false;
72 | padding: 8px;
73 | margin: -6px;
74 | horizontal-align: 0;
75 | width: 300;
76 | background-color: @background;
77 | border: 6px;
78 | border-color: @primary;
79 | border-radius: 8px;
80 | cursor: text;
81 | }
82 |
83 | element {
84 | children: [dummy, element-box, dummy];
85 | padding: 5px;
86 | orientation: vertical;
87 | border: 0px;
88 | border-radius: 16px;
89 | background-color: transparent; /* Default background */
90 | }
91 |
92 | element selected {
93 | background-color: @primary; /* Solid color for selected item */
94 | }
95 |
96 | element-box {
97 | children: [element-icon, element-text];
98 | orientation: vertical;
99 | expand: false;
100 | cursor: pointer;
101 | }
102 |
103 | element-icon {
104 | padding: 10px;
105 | cursor: inherit;
106 | size: 33%;
107 | margin: 10px;
108 | }
109 |
110 | element-text {
111 | horizontal-align: 0.5;
112 | cursor: inherit;
113 | text-color: @text-color;
114 | }
115 |
116 | element-text selected {
117 | text-color: @text-color-selected;
118 | }
119 |
--------------------------------------------------------------------------------
/rofi/userInput.rasi:
--------------------------------------------------------------------------------
1 | configuration {
2 | font: "Sans 12";
3 | }
4 |
5 | * {
6 | background-color: rgba(0, 0, 0, 0.7);
7 | text-color: #FFFFFF;
8 | }
9 |
10 | window {
11 | fullscreen: true;
12 | transparency: "real";
13 | background-color: @background-color;
14 | }
15 |
16 | mainbox {
17 | children: [ message, listview, inputbar ];
18 | padding: 40% 30%;
19 | }
20 |
21 | message {
22 | border: 0;
23 | padding: 10px;
24 | margin: 0 0 20px 0;
25 | font: "Sans Bold 24"; /* Increased font size and made it bold */
26 | }
27 |
28 | inputbar {
29 | children: [ prompt, entry ];
30 | background-color: rgba(255, 255, 255, 0.1);
31 | padding: 8px;
32 | border-radius: 4px;
33 | }
34 |
35 | prompt {
36 | padding: 8px;
37 | }
38 |
39 | entry {
40 | padding: 8px;
41 | }
42 |
43 | listview {
44 | lines: 0;
45 | }
46 |
47 | /* Style for the message text specifically */
48 | textbox {
49 | horizontal-align: 0.5; /* Center the text */
50 | font: "Sans Bold 24"; /* Match message font */
51 | }
--------------------------------------------------------------------------------