├── .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 | } --------------------------------------------------------------------------------