├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── addic7ed.go ├── addic7ed_test.go ├── filters.go ├── go.mod └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | .DS_Store 27 | coverage.txt 28 | vendor/ 29 | .vscode 30 | .idea 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.13" 5 | 6 | install: 7 | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.21.0 8 | 9 | script: 10 | - go test ./... -race -coverprofile=coverage.txt -covermode=atomic 11 | - golangci-lint run 12 | 13 | after_success: 14 | - bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks so much for wanting to help! We really appreciate it. 4 | 5 | * Have an idea for a new feature? 6 | * Want to add a new built-in theme? 7 | 8 | Excellent! You've come to the right place. 9 | 10 | 1. If you find a bug or wish to suggest a new feature, please create an issue first 11 | 2. Make sure your code & comment conventions are in-line with the project's style (execute gometalinter as in [.travis.yml](.travis.yml) file) 12 | 3. Make your commits and PRs as tiny as possible - one feature or bugfix at a time 13 | 4. Write detailed commit messages, in-line with the project's commit naming conventions -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mathieu Cornic 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 | # Addic7ed API 2 | 3 | [![Build Status](https://travis-ci.org/matcornic/addic7ed.svg?branch=master)](https://travis-ci.org/matcornic/addic7ed) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/matcornic/addic7ed)](https://goreportcard.com/report/github.com/matcornic/addic7ed) 5 | [![Go Coverage](https://codecov.io/github/matcornic/addic7ed/coverage.svg)](https://codecov.io/github/matcornic/addic7ed/) 6 | [![Godoc](https://godoc.org/github.com/matcornic/addic7ed?status.svg)](https://godoc.org/github.com/matcornic/addic7ed) 7 | 8 | `addic7ed` is a Golang package to get subtitles from [Addic7ed](http://www.addic7ed.com/) website. As Addic7ed website does not provide a proper API yet, this package uses search feature of website and scraps HTML results to build data. 9 | 10 | ## Installation 11 | 12 | As any golang package, just download it with `go get`. 13 | 14 | ```bash 15 | go get -u github.com/matcornic/addic7ed 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### Searching all subtitles of a given TV show 21 | 22 | ```golang 23 | c := addic7ed.New() 24 | show, err := c.SearchAll("Shameless.US.S08E11.720p.HDTV.x264-BATV[ettv]") // Usually the name of the video file 25 | if err != nil { 26 | panic(err) 27 | } 28 | fmt.Println(show.Name) // Output: Shameless (US) - 08x11 - A Gallagher Pedicure 29 | fmt.Println(show.Subtitles) // Output: all subtitles with version, languages and download links 30 | ``` 31 | 32 | In order to find all the subtitles, this API: 33 | 34 | 1. Use `search.php` page of Addic7ed API 35 | 1. Parse the results 36 | 37 | It means that if the tv show name is not precise enough, this API will not be able to find the exact TV show page. 38 | 39 | ### Searching the best subtitle of a given TV show 40 | 41 | ```golang 42 | c := addic7ed.New() 43 | showName, subtitle, err := c.SearchBest("Shameless.US.S08E11.720p.HDTV.x264-BATV[ettv]", "English") 44 | if err != nil { 45 | panic(err) 46 | } 47 | fmt.Println(showName) // Output: Shameless (US) - 08x11 - A Gallagher Pedicure 48 | fmt.Println(subtitle) // Output: the best suitable subtitle in English language 49 | fmt.Println(subtitle.Version) // Output: BATV 50 | fmt.Println(subtitle.Language) // Output: English 51 | 52 | // Download the subtitle to a given file name 53 | err := subtitle.DownloadTo("Shameless.US.S08E11.720p.HDTV.x264-BATV[ettv].srt") 54 | if err != nil { 55 | panic(err) 56 | } 57 | ``` 58 | 59 | In order to search the best subtitle, this API: 60 | 61 | 1. Filters subtitles of the given language. Here: `English` 62 | 1. Scores similarities between the name of the show and available versions (combining Jaro-winkler distance and an internal weight) 63 | 1. It means that the name of the show has to contain the `version`. Here: `BATV` 64 | 1. Choose the version with the best score 65 | 1. Choose the best subtitle of the chosen version (the most updated one) 66 | 67 | ### Helper functions 68 | 69 | Some helper functions are provided to adapt `subtitles` structure to the context 70 | 71 | ```golang 72 | c := addic7ed.New() 73 | show, err := c.SearchAll("Shameless.US.S08E11.720p.HDTV.x264-BATV[ettv]") // Usually the name of the video file 74 | if err != nil { 75 | panic(err) 76 | } 77 | 78 | // Filter subtitles to keep only english subtitles 79 | subtitles = show.Subtitles.Filter(WithLanguage("English")) 80 | // Group by version 81 | subtitlesByVersion = subtitles.GroupByVersion() 82 | fmt.Println(subtitlesByVersion["BATV"]) // Output: print all english subtitles of BATV version 83 | ``` 84 | 85 | Available filter functions: 86 | 87 | - `WithLanguage` 88 | - `WithVersion` 89 | - `WithVersionRegexp` 90 | 91 | Available groupBy functions: 92 | 93 | - `GroupByVersion` 94 | - `GroupByLanguage` 95 | 96 | ## Contributing 97 | 98 | See [CONTRIBUTING.md](CONTRIBUTING.md) 99 | 100 | ## Licence 101 | 102 | MIT. This package is not affiliated with Addic7ed website. -------------------------------------------------------------------------------- /addic7ed.go: -------------------------------------------------------------------------------- 1 | package addic7ed 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "strings" 11 | "unicode" 12 | 13 | "github.com/PuerkitoBio/goquery" 14 | textdistance "github.com/masatana/go-textdistance" 15 | ) 16 | 17 | const userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/12.0" 18 | 19 | // Client is the addic7ed client 20 | type Client struct { 21 | // doc is the indexed document, representing the page 22 | doc *goquery.Document 23 | debug bool 24 | } 25 | 26 | // New creates an Addic7ed client, ready to interact with. 27 | func New() *Client { 28 | return &Client{} 29 | } 30 | 31 | // NewVerbose creates a new client that will log verbosely to stdout 32 | func NewVerbose() *Client { 33 | return &Client{ 34 | debug: true, 35 | } 36 | } 37 | 38 | // Debug is used to set logging to verbose 39 | func (c *Client) Debug(isVerbose bool) { 40 | c.debug = isVerbose 41 | } 42 | 43 | func (c *Client) logf(message string, params ...interface{}) { 44 | if c.debug { 45 | fmt.Printf(message+"\n", params...) 46 | } 47 | } 48 | 49 | func (c *Client) log(message string) { 50 | if c.debug { 51 | fmt.Println(message) 52 | } 53 | } 54 | 55 | func (c *Client) findShowName() (string, error) { 56 | var show string 57 | c.log("Searching for show name in current page...") 58 | c.doc.Find(".titulo").Contents().EachWithBreak(func(i int, s *goquery.Selection) bool { 59 | if !s.Is("small") { 60 | show = strings.TrimSpace(s.Text()) 61 | return false 62 | } 63 | return true 64 | }) 65 | if show == "" { 66 | c.log("Show name is not found in current indexed page") 67 | return "", errors.New("not found") 68 | } 69 | c.logf("Show name is: %v", show) 70 | return show, nil 71 | } 72 | 73 | func (c *Client) findResults() []string { 74 | results := []string{} 75 | c.doc.Find(".tabel").Each(func(i int, s *goquery.Selection) { 76 | s.Find("a").Each(func(j int, ss *goquery.Selection) { 77 | if url, ok := ss.Attr("href"); ok { 78 | results = append(results, url) 79 | } 80 | }) 81 | }) 82 | return results 83 | } 84 | 85 | func createDocFromURL(url string) (*goquery.Document, error) { 86 | client := &http.Client{} 87 | req, err := http.NewRequest("GET", url, nil) 88 | if err != nil { 89 | return nil, err 90 | } 91 | // Avoid getting cached pages 92 | req.Header.Add("Cache-Control", "no-cache") 93 | req.Header.Add("User-Agent", userAgent) 94 | 95 | resp, err := client.Do(req) 96 | if err != nil { 97 | return nil, fmt.Errorf("Unable to reach addic7ed server: %v", err) 98 | } 99 | defer resp.Body.Close() 100 | 101 | // We use goquery to fetch the page from Addic7ed in way that we can find data quickly like the JQuery way 102 | doc, err := goquery.NewDocumentFromReader(resp.Body) 103 | if err != nil { 104 | return nil, fmt.Errorf("Unable to construct document from server response: %v", err) 105 | } 106 | 107 | return doc, nil 108 | } 109 | 110 | // fetchShowPage get the addic7ed show page from Addic7ed website 111 | // It uses search function of the website to get the page 112 | // Return an error if the page is not found 113 | // If more than one result is returned, we get the first one to match 114 | func (c *Client) fetchShowPage(fileName string) (string, error) { 115 | 116 | c.log("Searching show using addic7ed search page...") 117 | url := fmt.Sprintf("http://www.addic7ed.com/srch.php?search=%v&Submit=Search", url.QueryEscape(fileName)) 118 | c.logf("Searching show using URL: %v", url) 119 | doc, err := createDocFromURL(url) 120 | if err != nil { 121 | return "", err 122 | } 123 | c.doc = doc 124 | c.log("Addic7ed is up and we found a page") 125 | 126 | show, err := c.findShowName() 127 | if err != nil { 128 | c.log("Current page is not a show page, trying to find what is it...") 129 | // Addic7ed did not find the page of the show from the search feature 130 | results := c.findResults() 131 | if len(results) == 0 { 132 | c.log("Current page is not a result page either. We don't know what it is.") 133 | return "", fmt.Errorf("show not found for filename %v", fileName) 134 | } 135 | // If more result, we get the first result 136 | c.logf("Current page is a results page containing %v resuts. It means the input filename matches with multiple shows.", len(results)) 137 | c.log("Getting show page from first result...") 138 | doc, err := createDocFromURL("http://www.addic7ed.com/" + results[0]) 139 | if err != nil { 140 | return "", err 141 | } 142 | c.log("We found a show page from first result") 143 | c.doc = doc 144 | show, err = c.findShowName() 145 | if err != nil { 146 | return "", err 147 | } 148 | } 149 | c.logf("Current page is a show page: %v", show) 150 | return show, nil 151 | } 152 | 153 | // cleanTitle cleans the title of useless words. 154 | // Title are usually of the format "Version BATV, 0.00 MBs", and we want to keep only "BATV" 155 | func cleanTitle(title string) string { 156 | parts := strings.Split(title, ",") 157 | clean := title 158 | if len(parts) >= 0 { 159 | clean = parts[0] 160 | } 161 | parts = strings.Fields(clean) 162 | if len(parts) >= 2 { 163 | clean = parts[1] 164 | } else if len(parts) == 1 { 165 | clean = parts[0] 166 | } 167 | return clean 168 | } 169 | 170 | // Parse the string to find words 171 | // The filename is split in words. A word is a a sequence of letters or numbers. 172 | // Every other character is a separator (space, dots, plus, minus...) 173 | func wordsFromString(s string) []string { 174 | return strings.FieldsFunc(s, func(c rune) bool { 175 | return !unicode.IsLetter(c) && !unicode.IsNumber(c) 176 | }) 177 | } 178 | 179 | // scoreBestSubVersions give score to subtitles versions 180 | // It searches for similarities in both the filename and the version 181 | // filename and versions are indexed by word, and the more there are common words, the more the version gets a good score 182 | // Similarity is computed from a scoring between word exact matching and word distance (with Jaro/Winkler distance algorithm) 183 | func (c *Client) scoreBestSubVersions(fileName string, subtitlesByVersion map[string]Subtitles) map[string]float64 { 184 | const weightWhenExactMatch = 10 185 | wordsFromTitle := wordsFromString(fileName) 186 | scores := map[string]float64{} 187 | c.logf("Computing scores for file %v...", fileName) 188 | for version := range subtitlesByVersion { 189 | versionWords := wordsFromString(version) 190 | exactMatchs := 0.0 191 | var similarityScore float64 192 | for _, subWordFromTitle := range wordsFromTitle { 193 | for _, subWordFromVersion := range versionWords { 194 | // Similarity is a float computed from Jaro/Winkler distance 195 | // 0 = no similarity at all, 1 = exact same string 196 | distanceScore := textdistance.JaroWinklerDistance(strings.ToLower(subWordFromVersion), strings.ToLower(subWordFromTitle)) 197 | if distanceScore > 0.9 { 198 | exactMatchs += distanceScore 199 | } 200 | similarityScore += distanceScore 201 | 202 | c.logf("--- Comparison: %v (version '%v' compared to '%v') - exact-matchs=%v => distance=%v", 203 | version, subWordFromVersion, subWordFromTitle, exactMatchs, distanceScore) 204 | } 205 | } 206 | searchCardinality := float64(len(versionWords) * len(wordsFromTitle)) // Number of comparisons 207 | c.logf("== Search cardinality = (words in Version=%v)x(words in Filename=%v) = %v", 208 | len(versionWords), len(wordsFromTitle), searchCardinality) 209 | // Will lower the similarity score if there were a lot of word to compare 210 | computedSimilarityScore := similarityScore / searchCardinality 211 | c.logf("== Computed similarity = (similarity=%v)/(searchCardinality=%v) = %v", 212 | similarityScore, searchCardinality, computedSimilarityScore, 213 | ) 214 | 215 | // By multiplying by the number of matches, we ensure that a version with 3 exact matches is better than a version with 2 exact matches. 216 | proportionExactMatchs := (exactMatchs) / float64(len(versionWords)) // Will tend to 1 (1 = all words in version are contained in filename) 217 | exactMatchScore := float64(proportionExactMatchs * (exactMatchs * weightWhenExactMatch)) 218 | c.logf("== Exact match score = (proportionOfExactMatchs=%v)x(exactMatch=%v)x(weigth=%v) = %v", 219 | proportionExactMatchs, exactMatchs, weightWhenExactMatch, exactMatchScore, 220 | ) 221 | 222 | scores[version] = computedSimilarityScore + exactMatchScore 223 | c.log("=============================================================================") 224 | c.logf("===> TOTAL SCORE FILE=%v VERSION=%v = (Computed similarity=%v)+(Exact match score=%v)=%v <===", 225 | fileName, version, computedSimilarityScore, exactMatchScore, scores[version], 226 | ) 227 | c.log("=============================================================================") 228 | } 229 | 230 | return scores 231 | } 232 | 233 | // findBestSubtitleFromScores returns the best suitable subtitle from the given scores 234 | func findBestSubtitleFromScores(scores map[string]float64, subtitlesByVersion map[string]Subtitles) (Subtitle, float64) { 235 | // Get best version from score 236 | var bestScore float64 237 | var bestVersion string 238 | for version, score := range scores { 239 | if score > bestScore { 240 | bestVersion = version 241 | bestScore = score 242 | } 243 | } 244 | 245 | // Unable to get the best version from scores, so we get the first to come ¯\_(ツ)_/¯ 246 | if bestVersion == "" { 247 | // As Go randomizes maps, the "first to come" version may be different between two runs with same input data 248 | for version := range subtitlesByVersion { 249 | bestVersion = version 250 | break 251 | } 252 | } 253 | 254 | bestSubs := subtitlesByVersion[bestVersion] 255 | 256 | // From a given version, keep the best subtitle 257 | // Addic7ed authorizes multiple subtitle of the same version, so we get the most updated one 258 | var bestSub Subtitle 259 | for _, sub := range bestSubs { 260 | if sub.IsUpdated() { 261 | bestSub = sub 262 | break 263 | } 264 | bestSub = sub 265 | } 266 | return bestSub, bestScore 267 | } 268 | 269 | // SearchBest searches in the Addic7ed website for the best suitable subtitle of given episode of a show 270 | // showStr is usually the name of the video file that need to be searched but it could be any search that can be handled by Addic7ed website 271 | // lang is the language of the subtitle 272 | // It returns the episode name and the found subtitle. 273 | func (c *Client) SearchBest(showStr, lang string) (string, Subtitle, error) { 274 | show, err := c.SearchAll(showStr) 275 | if err != nil { 276 | return "", Subtitle{}, err 277 | } 278 | c.log("Found subtitles:") 279 | for _, sub := range show.Subtitles { 280 | c.logf("- %v (%v) | %v", sub.Version, sub.Language, sub.Link) 281 | } 282 | subsWithLang := show.Subtitles.Filter(WithLanguage(lang)) 283 | if len(subsWithLang) == 0 { 284 | return "", Subtitle{}, fmt.Errorf("Unable to find any subtitles for show %q in %q. Check available languages on Addic7ed website and retry", show.Name, lang) 285 | } 286 | 287 | if len(subsWithLang) == 1 { 288 | c.logf("Only one subtitle found for lang %v", subsWithLang[0]) 289 | return show.Name, subsWithLang[0], nil 290 | } 291 | 292 | subsByVersion := subsWithLang.GroupByVersion() 293 | 294 | // Score the different version to find best suitable one 295 | c.logf("Found %v different versions of subtitles, trying to find the best one...", len(subsByVersion)) 296 | scores := c.scoreBestSubVersions(showStr, subsByVersion) 297 | if c.debug { 298 | c.log("Scores are:") 299 | for k, v := range scores { 300 | c.logf(" - Version: %v => Score: %v", k, v) 301 | } 302 | } 303 | 304 | // From the scores, find the best subtitle possible 305 | bestSub, bestScore := findBestSubtitleFromScores(scores, subsByVersion) 306 | c.logf("=> Best sub: %v (%v) with score %v", bestSub.Version, bestSub.Link, bestScore) 307 | 308 | return show.Name, bestSub, nil 309 | } 310 | 311 | // SearchAll searches in the Addic7ed website for a given episode of a show 312 | // showStr is usually the name of the video file that need to be searched but it could be any search that can be handled by Addic7ed website 313 | // It returns the episode name and all found subtitles. 314 | func (c *Client) SearchAll(showStr string) (Show, error) { 315 | showName, err := c.fetchShowPage(showStr) 316 | if err != nil { 317 | return Show{}, err 318 | } 319 | subtitles := Subtitles{} 320 | 321 | // Search for all HTML table with Addic7ed class tabel95 322 | c.doc.Find(".tabel95").Each(func(i int, s *goquery.Selection) { 323 | // Filter only table corresponding to a subtitle version 324 | if v, ok := s.Attr("align"); ok && v == "center" { 325 | // Fin the 326 | title := strings.TrimSpace(s.Find(".NewsTitle").Text()) 327 | s.Find(".language").Each(func(j int, ss *goquery.Selection) { 328 | language := ss.Text() 329 | ss.Parent().Find(".face-button").Each(func(k int, sss *goquery.Selection) { 330 | if val, ok := sss.Attr("href"); ok { 331 | link := "http://www.addic7ed.com" + val 332 | 333 | version := cleanTitle(title) 334 | subtitle := Subtitle{ 335 | Version: version, 336 | Language: strings.TrimSpace(language), 337 | Link: strings.TrimSpace(link), 338 | } 339 | subtitles = append(subtitles, subtitle) 340 | } 341 | }) 342 | }) 343 | } 344 | }) 345 | 346 | show := Show{ 347 | Name: showName, 348 | Subtitles: subtitles, 349 | } 350 | 351 | return show, nil 352 | } 353 | 354 | // Subtitle is a TV-Show subtitle 355 | type Subtitle struct { 356 | // Language is the Addic7ed language as seen in the website 357 | Language string 358 | // Version is the subtitle type/version, usually the name of the teams who ripped the tv show 359 | Version string 360 | // Link is the link to the subtitle from Addic7ed website 361 | Link string 362 | } 363 | 364 | func (s Subtitle) String() string { 365 | return fmt.Sprintf("Link: %v, Version: %v, Language: %v", s.Link, s.Version, s.Language) 366 | } 367 | 368 | // IsOriginal checks whether the subtitle is original. 369 | // It means that the subtitle comes with different version and this subtitle is the original one. 370 | func (s Subtitle) IsOriginal() bool { 371 | return !s.IsUpdated() 372 | } 373 | 374 | // Download download the subtitle in-memory, in a closable reader 375 | func (s Subtitle) Download() (io.ReadCloser, error) { 376 | client := &http.Client{} 377 | req, err := http.NewRequest("GET", s.Link, nil) 378 | if err != nil { 379 | return nil, err 380 | } 381 | // Avoid getting cached pages 382 | req.Header.Add("Cache-Control", "no-cache") 383 | req.Header.Add("User-Agent", userAgent) 384 | req.Header.Add("Referer", s.Link) // Without it, the Addic7ed server redirect to the web page instead of dl the srt file 385 | 386 | resp, err := client.Do(req) 387 | if err != nil { 388 | return nil, fmt.Errorf("Unable to reach addic7ed server: %v", err) 389 | } 390 | return resp.Body, nil 391 | } 392 | 393 | // DownloadTo downloads the subtitle to a given path 394 | func (s Subtitle) DownloadTo(path string) error { 395 | sub, err := s.Download() 396 | if err != nil { 397 | return err 398 | } 399 | defer sub.Close() 400 | 401 | w, err := os.Create(path) 402 | if err != nil { 403 | return err 404 | } 405 | defer w.Close() 406 | 407 | _, err = io.Copy(w, sub) 408 | return err 409 | } 410 | 411 | // IsUpdated checks whether the subtitle is updated. 412 | // It means that the subtitle comeswith different version and this subtitle is the updated one. 413 | func (s Subtitle) IsUpdated() bool { 414 | return strings.Contains(s.Link, "updated") 415 | } 416 | 417 | // Subtitles is a slice of subtitle 418 | type Subtitles []Subtitle 419 | 420 | func (ss Subtitles) String() string { 421 | subtitles := []string{} 422 | for _, s := range ss { 423 | subtitles = append(subtitles, s.String()) 424 | } 425 | return fmt.Sprintf("[{%v}]", strings.Join(subtitles, "},{")) 426 | } 427 | 428 | // Filter filters out subtitles 429 | // To use it, you have to provide a function that returns true for Subtitles to keep, and false to the one to ignore. 430 | // See addic7ed.WithLanguage, addic7ed.WithVersion, addic7ed.WithVersionRegexp for built-in filters 431 | func (ss Subtitles) Filter(filter func(s Subtitle) bool) Subtitles { 432 | subtitles := Subtitles{} 433 | for _, subtitle := range ss { 434 | if filter(subtitle) { 435 | subtitles = append(subtitles, subtitle) 436 | } 437 | } 438 | return subtitles 439 | } 440 | 441 | // GroupBy groups subtitles by a given property from the subtitle 442 | func (ss Subtitles) GroupBy(property func(s Subtitle) string) map[string]Subtitles { 443 | groupBy := map[string]Subtitles{} 444 | for _, s := range ss { 445 | propKey := property(s) 446 | if val, ok := groupBy[propKey]; ok { 447 | val = append(val, s) 448 | groupBy[propKey] = val 449 | } else { 450 | groupBy[propKey] = Subtitles{s} 451 | } 452 | } 453 | return groupBy 454 | } 455 | 456 | // GroupByVersion groups subtitles by version 457 | func (ss Subtitles) GroupByVersion() map[string]Subtitles { 458 | return ss.GroupBy(func(s Subtitle) string { 459 | return s.Version 460 | }) 461 | } 462 | 463 | // GroupByLanguage groups subtitles by language 464 | func (ss Subtitles) GroupByLanguage() map[string]Subtitles { 465 | return ss.GroupBy(func(s Subtitle) string { 466 | return s.Language 467 | }) 468 | } 469 | 470 | // Show defines a TV show with a name and associated subtitle 471 | type Show struct { 472 | Name string 473 | Subtitles Subtitles 474 | } 475 | -------------------------------------------------------------------------------- /addic7ed_test.go: -------------------------------------------------------------------------------- 1 | package addic7ed_test 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/matcornic/addic7ed" 11 | ) 12 | 13 | func TestAddic7edSearchAllWithGoodShow(t *testing.T) { 14 | c := addic7ed.New() 15 | var flagtests = []struct { 16 | inFile string 17 | expectedShowName string 18 | }{ 19 | {"Shameless.US.S08E11.720p.HDTV.x264-BATV[ettv]", "Shameless (US) - 08x11 - A Gallagher Pedicure"}, 20 | {"This is Us S01E02", "This is Us - 01x02 - The Big Three"}, 21 | {"Transparent 04x09 AMZ.WEB-DL-NTb;WEB.h264-STRIFE", "Transparent - 04x09 - They is on the Way"}, 22 | {"The Big Bang Theory - 06x12 - Web-dl 480p", "The Big Bang Theory - 06x12 - The Egg Salad Equivalency"}, 23 | {"Mr.Robot.S03E09.720p.HDTV.x264-AVS", "Mr. Robot - 03x09 - eps3.8_stage3.torrent"}, 24 | {"Dark.S01E05.720p.WEBRip.x264-STRiFE", "Dark - 01x05 - Truths"}, 25 | } 26 | 27 | for _, test := range flagtests { 28 | t.Logf("Searching all subtitles for filename %v\n", test.inFile) 29 | actualShow, err := c.SearchAll(test.inFile) 30 | assert.NoError(t, err) 31 | t.Logf("%v: %v", actualShow.Name, actualShow.Subtitles) 32 | assert.Equal(t, test.expectedShowName, actualShow.Name) 33 | assert.NotEmpty(t, actualShow.Subtitles) 34 | } 35 | } 36 | 37 | func TestAddic7edAllWithUnknownShow(t *testing.T) { 38 | c := addic7ed.New() 39 | fileName := "DoesNotExist" 40 | t.Logf("Searching show and subtitles with filename: %v\n", fileName) 41 | show, err := c.SearchAll(fileName) 42 | if err == nil { 43 | t.Logf("Show found: %v\n", show.Name) 44 | t.Logf("Subtitles found: %v\n", show.Subtitles) 45 | t.Fatal(errors.New("Should not have found show")) 46 | } 47 | 48 | t.Logf("Got expected error: %v\n", err) 49 | } 50 | 51 | func TestAddic7edSearchBestWithGoodShow(t *testing.T) { 52 | c := addic7ed.NewVerbose() 53 | var flagtests = []struct { 54 | inFile string 55 | inLang string 56 | expectedShowName string 57 | expectedVersion string 58 | }{ 59 | {"Shameless.US.S08E11.720p.HDTV.x264-BATV[ettv]", "English", "Shameless (US) - 08x11 - A Gallagher Pedicure", "BATV"}, 60 | {"This is Us S01E02", "English", "This is Us - 01x02 - The Big Three", "KILLERS"}, 61 | {"Transparent 04x09 AMZ.WEB-DL-NTb;WEB.h264-STRIFE", "English", "Transparent - 04x09 - They is on the Way", "AMZ.WEB-DL-NTb;WEB.h264-STRIFE"}, 62 | {"The Big Bang Theory - 06x12 - Web-dl 480p", "English", "The Big Bang Theory - 06x12 - The Egg Salad Equivalency", "480p.WEB-DL"}, 63 | {"The Big Bang Theory - 06x12 - Web-dl 480", "English", "The Big Bang Theory - 06x12 - The Egg Salad Equivalency", "480p.WEB-DL"}, 64 | {"Mr.Robot.S03E09.720p.HDTV.x264-AVS", "English", "Mr. Robot - 03x09 - eps3.8_stage3.torrent", "AVS-SVA"}, 65 | {"Dark.S01E05.720p.WEBRip.x264-STRiFE", "English", "Dark - 01x05 - Truths", "WEBRip.x264-STRiFE"}, 66 | {"The.Good.Fight.S01E02.1080p.WEB-DL.DD5.1.H264-ViSUM[rartv]", "English", "The Good Fight - 01x02 - First Week", "WEBRip"}, 67 | {"Lethal.Weapon.S01E11.Lawmen.720p.AMZN.WEBRip.DD5.1.x264-NTb[rartv]", "English", "Lethal Weapon - 01x11 - Lawmen", "WEB-DL"}, 68 | {"The.Walking.Dead.S07E11.Hostiles.and.Calamities.720p.AMZN.WEBRip.DD5.1.x264-CasStudio[rartv]", "English", "The Walking Dead - 07x11 - Hostiles and Calamities", "AMZN.WEBRip"}, 69 | } 70 | 71 | for _, test := range flagtests { 72 | t.Logf("Searching best subtitle for filename %v and language %v\n", test.inFile, test.inLang) 73 | actualShowName, actualSubtitle, err := c.SearchBest(test.inFile, test.inLang) 74 | assert.NoError(t, err) 75 | t.Logf("%v: %v", actualShowName, actualSubtitle) 76 | assert.Equal(t, test.expectedShowName, actualShowName) 77 | assert.Equal(t, test.expectedVersion, actualSubtitle.Version) 78 | assert.Equal(t, test.inLang, actualSubtitle.Language) 79 | } 80 | } 81 | 82 | func TestAddic7edBestWithUnknownShow(t *testing.T) { 83 | c := addic7ed.New() 84 | fileName := "DoesNotExist" 85 | t.Logf("Searching show and subtitles with filename: %v\n", fileName) 86 | _, _, err := c.SearchBest(fileName, "English") 87 | assert.Error(t, err) 88 | t.Logf("Got expected error: %v\n", err) 89 | } 90 | 91 | func TestAddic7edBestWithGoodShowButNotSubtitle(t *testing.T) { 92 | c := addic7ed.New() 93 | fileName := "Shameless.US.S08E11.720p.HDTV.x264-BATV[ettv]" 94 | t.Logf("Searching show and subtitles with filename: %v\n", fileName) 95 | _, _, err := c.SearchBest(fileName, "Japanese") 96 | assert.Error(t, err) 97 | t.Logf("Got expected error: %v\n", err) 98 | } 99 | 100 | func TestFilterShow(t *testing.T) { 101 | subs := addic7ed.Subtitles{ 102 | {Version: "A", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 103 | {Version: "A", Language: "French", Link: "http://addic7ed.com/A-good-show-2"}, 104 | {Version: "B", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 105 | {Version: "A", Language: "English", Link: "http://addic7ed.com/A-good-show"}, 106 | {Version: "A", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 107 | {Version: "C", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 108 | } 109 | subsWithVersionA := subs.Filter(addic7ed.WithVersion("A")) 110 | assert.Len(t, subsWithVersionA, 4) 111 | subsWithVersionAAndLanguageFrench := subsWithVersionA.Filter(addic7ed.WithLanguage("french")) 112 | assert.Len(t, subsWithVersionAAndLanguageFrench, 2) 113 | } 114 | 115 | func TestGroupByVersion(t *testing.T) { 116 | subs := addic7ed.Subtitles{ 117 | {Version: "A", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 118 | {Version: "A", Language: "French", Link: "http://addic7ed.com/A-good-show-2"}, 119 | {Version: "B", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 120 | {Version: "A", Language: "English", Link: "http://addic7ed.com/A-good-show"}, 121 | {Version: "A", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 122 | {Version: "C", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 123 | } 124 | groupByVersion := subs.GroupByVersion() 125 | assert.Len(t, groupByVersion, 3) 126 | assert.Len(t, groupByVersion["A"], 4) 127 | assert.Len(t, groupByVersion["B"], 1) 128 | assert.Len(t, groupByVersion["C"], 1) 129 | } 130 | 131 | func TestGroupByLanguage(t *testing.T) { 132 | subs := addic7ed.Subtitles{ 133 | {Version: "A", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 134 | {Version: "A", Language: "French", Link: "http://addic7ed.com/A-good-show-2"}, 135 | {Version: "B", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 136 | {Version: "A", Language: "English", Link: "http://addic7ed.com/A-good-show"}, 137 | {Version: "A", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 138 | {Version: "C", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 139 | } 140 | groupByLang := subs.GroupByLanguage() 141 | assert.Len(t, groupByLang, 3) 142 | assert.Len(t, groupByLang["French"], 3) 143 | assert.Len(t, groupByLang["English"], 1) 144 | assert.Len(t, groupByLang["Italian"], 2) 145 | } 146 | 147 | func TestFilterRegexp(t *testing.T) { 148 | subs := addic7ed.Subtitles{ 149 | {Version: "TV-something", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 150 | {Version: "1080p+WAHOO-Team+Dolby-Surround", Language: "French", Link: "http://addic7ed.com/A-good-show-2"}, 151 | {Version: "Another team", Language: "French", Link: "http://addic7ed.com/A-good-show"}, 152 | {Version: "AMZON", Language: "English", Link: "http://addic7ed.com/A-good-show"}, 153 | {Version: "AAMZN.NTb+DEFLATE+ION10", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 154 | {Version: "WUT", Language: "Italian", Link: "http://addic7ed.com/A-good-show"}, 155 | } 156 | regex := regexp.MustCompile(".*WAHOO-Team.*") 157 | subtitles := subs.Filter(addic7ed.WithVersionRegexp(regex)) 158 | assert.Len(t, subtitles, 1) 159 | } 160 | -------------------------------------------------------------------------------- /filters.go: -------------------------------------------------------------------------------- 1 | package addic7ed 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | // WithLanguage is a filter first-class function, used to keep subtitle with given language 9 | func WithLanguage(lang string) func(s Subtitle) bool { 10 | return func(s Subtitle) bool { 11 | return strings.EqualFold(strings.TrimSpace(s.Language), strings.TrimSpace(lang)) 12 | } 13 | } 14 | 15 | // WithVersion is a filter first-class function, used to keep subtitle with given subtitle version 16 | func WithVersion(version string) func(s Subtitle) bool { 17 | return func(s Subtitle) bool { 18 | return strings.EqualFold(strings.TrimSpace(s.Version), strings.TrimSpace(version)) 19 | } 20 | } 21 | 22 | // WithVersionRegexp is a filter first-class function, used to keep subtitle with given subtitle version identified by a regex 23 | func WithVersionRegexp(version *regexp.Regexp) func(s Subtitle) bool { 24 | return func(s Subtitle) bool { 25 | return version.MatchString(strings.TrimSpace(s.Version)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matcornic/addic7ed 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.8 6 | 7 | require ( 8 | github.com/PuerkitoBio/goquery v1.10.2 9 | github.com/masatana/go-textdistance v0.0.0-20191005053614-738b0edac985 10 | github.com/stretchr/testify v1.10.0 11 | ) 12 | 13 | require ( 14 | github.com/andybalholm/cascadia v1.3.3 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/deckarep/golang-set v1.8.0 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | golang.org/x/net v0.35.0 // indirect 19 | gopkg.in/yaml.v3 v3.0.1 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= 2 | github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= 3 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 4 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= 8 | github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= 9 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 10 | github.com/masatana/go-textdistance v0.0.0-20191005053614-738b0edac985 h1:Pz8zZjVRvKxISYimNzLGnzSNl5hYXFSN80FPQ+qt1HE= 11 | github.com/masatana/go-textdistance v0.0.0-20191005053614-738b0edac985/go.mod h1:1nU7rI+iBPtzc9ZKOqeQacD290rA0wcJLu5AtOSBBPw= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 15 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 16 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 17 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 18 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 19 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 20 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 21 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 22 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 23 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 24 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 25 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 26 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 27 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 28 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 29 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 30 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 31 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 32 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 33 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 34 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 35 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 36 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 37 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 38 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 39 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 42 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 43 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 44 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 45 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 46 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 47 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 48 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 55 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 56 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 57 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 58 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 59 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 60 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 61 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 62 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 63 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 64 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 65 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 66 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 67 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 68 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 69 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 70 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 71 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 72 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 73 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 74 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 75 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 76 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 77 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 78 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 79 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 80 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 81 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 82 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 83 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 84 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 85 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 86 | --------------------------------------------------------------------------------