├── .gitignore ├── LICENSE ├── README.md ├── cmd ├── file.go ├── root.go └── url.go ├── go.mod ├── go.sum ├── main.go ├── resources └── demo.gif ├── types └── types.go └── utils ├── print.go ├── search.go ├── spinner.go └── timestamp.go /.gitignore: -------------------------------------------------------------------------------- 1 | samples/ 2 | TODO.md 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Cade Cuddy 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 |

🥫 sauce - CLI anime identification 2 | 3 |

sauce uses trace.moe to identify the anime in an image & serves you its essential details so you can determine if it's worth the watch.

4 | 5 |

6 | demo 7 |

8 | 9 |

never find yourself asking "sauce?" ever again! 10 | 11 | #### This project was inspired by [what-anime-cli](https://github.com/irevenko/what-anime-cli/) by [irevenko](https://github.com/irevenko). I felt there was more utility in looking up the identified anime's MyAnimeList stats, so that was the approach I took with sauce. 12 | 13 | ## 🔧 Installation 14 | 15 | Install with [Go](https://go.dev/) install: 16 | ```bash 17 | go install github.com/cadecuddy/sauce@latest 18 | ``` 19 | 20 | ## 💻 Usage 21 | **Known supported image types: jpg, png, webp, gif** 22 | 23 | ### 🔗 search by image url 24 | `sauce url ` 25 | ```bash 26 | sauce url https://findthis.jp/anime.png 27 | ``` 28 | 29 | ### 📂 search by image file 30 | `sauce file ` 31 | ```bash 32 | sauce file demon-slayer.png 33 | ``` 34 | 35 | ## 📟 Environment Setup (contributors) 36 | 37 | `git clone https://github.com/cadecuddy/sauce.git`
38 | `cd sauce`
39 | 40 | If you find any bugs or want to add any cool features feel free to leave a PR! 41 | 42 | ## 🤝 made with 43 | * [trace.moe](https://soruly.github.io/trace.moe-api/#/) - anime identification 44 | * [jikan-go](https://github.com/darenliang/jikan-go) - MyAnimeList data 45 | -------------------------------------------------------------------------------- /cmd/file.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "mime/multipart" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/cadecuddy/sauce/utils" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | const FILE_SEARCH = "https://api.trace.moe/search" 16 | 17 | // fileCmd represents the file command 18 | var fileCmd = &cobra.Command{ 19 | Use: "file", 20 | Short: "Search for anime source using a path to local file.", 21 | Long: `Search for anime source using a path to local file. 22 | 23 | File searches support most visual file mediums under 25MB including: 24 | - png 25 | - jpeg/jpg 26 | - gif 27 | - webp 28 | - many more! 29 | `, 30 | Run: func(cmd *cobra.Command, args []string) { 31 | fileSearch(args[0]) 32 | }, 33 | Args: cobra.MinimumNArgs(1), 34 | } 35 | 36 | // Searches for anime based on file path input 37 | func fileSearch(filepath string) { 38 | file, err := os.Open(filepath) 39 | if err != nil { 40 | fmt.Println("❌ Invalid file") 41 | return 42 | } 43 | defer file.Close() 44 | 45 | s := utils.ConfigSpinner() 46 | s.Start() 47 | 48 | // form file upload via https://gist.github.com/andrewmilson/19185aab2347f6ad29f5 49 | buffer := &bytes.Buffer{} 50 | writer := multipart.NewWriter(buffer) 51 | part, _ := writer.CreateFormFile("file", filepath) 52 | io.Copy(part, file) 53 | writer.Close() 54 | 55 | res, err := http.Post(URL_SEARCH, writer.FormDataContentType(), buffer) 56 | identifiedAnime, malData := utils.HandleResponse(res, err, s) 57 | 58 | s.Stop() 59 | 60 | // do things 61 | utils.PrintSauce(identifiedAnime, malData.Data) 62 | } 63 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // rootCmd represents the base command when called without any subcommands 10 | var rootCmd = &cobra.Command{ 11 | Use: "sauce", 12 | Short: "🥫 sauce is a CLI tool to find the source of anime screenshots, gifs, clips, etc.", 13 | } 14 | 15 | // Execute adds all child commands to the root command and sets flags appropriately. 16 | // This is called by main.main(). It only needs to happen once to the rootCmd. 17 | func Execute() { 18 | rootCmd.SetUsageTemplate(`Usage: 19 | sauce [command] 20 | 21 | Available Commands: 22 | file Search using a path to local file. 23 | url Search using a url to media. 24 | help Help about any command. 25 | 26 | Use "sauce [command] --help" for more information about a command. 27 | `) 28 | rootCmd.AddCommand(urlCmd) 29 | rootCmd.AddCommand(fileCmd) 30 | err := rootCmd.Execute() 31 | if err != nil { 32 | os.Exit(1) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/url.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/cadecuddy/sauce/utils" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | const URL_SEARCH string = "https://api.trace.moe/search?anilistInfo&url=" 13 | 14 | // urlCmd represents the url command 15 | var urlCmd = &cobra.Command{ 16 | Use: "url", 17 | Short: "Search for anime source using the url to the media.", 18 | Long: `Search for anime source using the url to the media.`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | urlSearch(args[0]) 21 | }, 22 | Args: cobra.MinimumNArgs(1), 23 | } 24 | 25 | // Makes a search via URL to the unidentified anime media. 26 | // 27 | // Checks the URL and once verififed, queries the trace.moe API, 28 | // getting the core information of the show. 29 | func urlSearch(linkToMedia string) { 30 | // validate URL 31 | _, err := url.ParseRequestURI(linkToMedia) 32 | if err != nil { 33 | fmt.Println("❌ Invalid URL") 34 | return 35 | } 36 | s := utils.ConfigSpinner() 37 | s.Start() 38 | 39 | // make GET request to trace.moe API 40 | resp, err := http.Get(URL_SEARCH + linkToMedia) 41 | identifiedAnime, malData := utils.HandleResponse(resp, err, s) 42 | 43 | s.Stop() 44 | 45 | // use highest similarity accuracy 46 | utils.PrintSauce(identifiedAnime, malData.Data) 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cadecuddy/sauce 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/briandowns/spinner v1.19.0 7 | github.com/darenliang/jikan-go v1.2.3 8 | github.com/dustin/go-humanize v1.0.0 9 | github.com/fatih/color v1.13.0 10 | github.com/spf13/cobra v1.5.0 11 | ) 12 | 13 | require ( 14 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 15 | github.com/mattn/go-colorable v0.1.9 // indirect 16 | github.com/mattn/go-isatty v0.0.14 // indirect 17 | github.com/spf13/pflag v1.0.5 // indirect 18 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= 2 | github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/darenliang/jikan-go v1.2.3 h1:Nw6ykJU47QW3rwiIBWHyy1cBNM1Cxsz0AVCdqIN278A= 5 | github.com/darenliang/jikan-go v1.2.3/go.mod h1:rv7ksvNqc1b0UK7mf1Uc3swPToJXd9EZQLz5C38jk9Q= 6 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 7 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 8 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 9 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 10 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 11 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 12 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 13 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 14 | github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= 15 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 16 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 17 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 18 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 19 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 20 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 21 | github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= 22 | github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 23 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 24 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 25 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 26 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 29 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 CADE CUDDY 3 | MADE WITH: trace.moe API, JIKAN API 4 | */ 5 | package main 6 | 7 | import "github.com/cadecuddy/sauce/cmd" 8 | 9 | func main() { 10 | cmd.Execute() 11 | } 12 | -------------------------------------------------------------------------------- /resources/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadecuddy/sauce/b90ecf0c6a5274bfd1efe6991bc638560131397b/resources/demo.gif -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Holds unmarshalled trace.moe JSON response data 4 | type MoeResponse struct { 5 | Framecount int `json:"frameCount"` 6 | Error string `json:"error"` 7 | Result []Result `json:"result"` 8 | } 9 | 10 | // Body of relevant data when a successful response goes through 11 | type Result struct { 12 | Anilist AnilistData `json:"anilist"` 13 | Filename string `json:"filename"` 14 | Episode int `json:"episode"` 15 | From float64 `json:"from"` 16 | To float64 `json:"to"` 17 | Similarity float64 `json:"similarity"` 18 | Video string `json:"video"` 19 | Image string `json:"image"` 20 | } 21 | 22 | // Anilist information related to show/episode 23 | type AnilistData struct { 24 | ID int `json:"id"` 25 | MalID int `json:"idMal"` 26 | Title struct { 27 | Native string `json:"native"` 28 | Romaji string `json:"romaji"` 29 | English string `json:"english"` 30 | } `json:"title"` 31 | Synonyms []string `json:"synonyms"` 32 | IsAdult bool `json:"isAdult"` 33 | } 34 | -------------------------------------------------------------------------------- /utils/print.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/cadecuddy/sauce/types" 8 | "github.com/darenliang/jikan-go" 9 | "github.com/dustin/go-humanize" 10 | "github.com/fatih/color" 11 | ) 12 | 13 | const ( 14 | PREFIX_LENGTH int = 12 15 | BORDER_MAX = 28 16 | ) 17 | 18 | // Prints the output of the result of the search to the terminal 19 | func PrintSauce(res types.Result, malData jikan.AnimeBase) { 20 | b := color.New(color.Bold) 21 | color.New(color.FgGreen).Add(color.Bold).Printf("✅ sauce found : [%f similarity]\n", res.Similarity) 22 | 23 | // regulate size & print top flower border 24 | size := getBorderSize(res.Anilist.Title.Romaji, res.Anilist.Title.English) 25 | if size >= BORDER_MAX { 26 | size = BORDER_MAX 27 | } 28 | println(strings.Repeat("🌸", size)) 29 | println() 30 | 31 | formatTitle(res, size) 32 | formatType(res, malData) 33 | formatScore(malData.Score) 34 | b.Print("🏆 Ranking: ") 35 | color.New(color.FgHiMagenta).Printf("#%s\n", humanize.Comma(int64(malData.Rank))) 36 | b.Print("📕 Source: ") 37 | color.Red(" %s", malData.Source) 38 | // Movies won't have their year load from MAL Data 39 | if malData.Year != 0 { 40 | b.Print("📅 Year: ") 41 | color.Cyan(" %s %d", strings.Title(malData.Season), malData.Year) 42 | } 43 | formatGenre(malData.Genres) 44 | b.Print("🎬 Studio: ") 45 | color.New(color.FgGreen).Println(malData.Studios[0].Name) 46 | 47 | // Print bottom border 48 | println() 49 | println(strings.Repeat("🌸", size)) 50 | } 51 | 52 | // Helper function that crudely calculates the total flower border size. 53 | // 54 | // Maximum border size is 55 | func getBorderSize(nativeTitle string, englishTitle string) int { 56 | var borderLength = PREFIX_LENGTH 57 | 58 | if nativeTitle == englishTitle { 59 | borderLength += len(nativeTitle) 60 | } else { 61 | borderLength += len(nativeTitle) + len(englishTitle) + 3 62 | } 63 | 64 | return int(float32(borderLength)) 65 | } 66 | 67 | // Prints the Anime's native title as well as an English 68 | // translation if one is available 69 | func formatTitle(res types.Result, borderSize int) { 70 | color.New(color.Bold).Print("✨ Anime: ") 71 | var title string 72 | if len(res.Anilist.Title.English) != 0 { 73 | title = fmt.Sprintf("%s (%s)", res.Anilist.Title.Native, res.Anilist.Title.English) 74 | } else { 75 | title = fmt.Sprintf("%s (%s)", res.Anilist.Title.Native, res.Anilist.Title.Romaji) 76 | } 77 | color.New(color.Bold).Printf("%s\n", title) 78 | } 79 | 80 | // Formats the episode/timestamp sections depending on the media form 81 | // of the anime. TV shows will print the episode section. Movies will 82 | // print a different 'Scene' section containing the scene's timestamp 83 | func formatType(res types.Result, malData jikan.AnimeBase) { 84 | b := color.New(color.Bold) 85 | b.Print("❓ Type: ") 86 | 87 | if malData.Type == "Movie" { 88 | fmt.Println("Movie 🎥") 89 | b.Print("🕐 Scene: ") 90 | formatTimestamp(res.From, res.To) 91 | return 92 | } else { 93 | fmt.Println("TV Show 📺") 94 | formatEpisodes(res.Episode, malData.Episodes, res.From, res.To) 95 | return 96 | } 97 | 98 | } 99 | 100 | // Formats and prints the episode count if the detected anime is a TV Show 101 | func formatEpisodes(episode int, totalEpisodes int, timestampFrom float64, timestampTo float64) { 102 | color.New(color.Bold).Print("🕐 Episode: ") 103 | 104 | if totalEpisodes != 0 { 105 | fmt.Printf("%d/%d @", episode, totalEpisodes) 106 | } else { 107 | color.New(color.FgRed).Printf("%d @", episode) 108 | } 109 | formatTimestamp(timestampFrom, timestampTo) 110 | } 111 | 112 | // Generate the scene's timestamp in the anime 113 | func formatTimestamp(from float64, to float64) { 114 | color.New(color.FgHiBlue).Add(color.Bold).Printf(" [%s - %s]\n", ConvertTimestamp(from), ConvertTimestamp(to)) 115 | } 116 | 117 | // Prints the anime's genres as found on MAL 118 | func formatGenre(genres []jikan.MalItem) { 119 | color.New(color.Bold).Print("📜 Genres: ") 120 | r := color.New(color.FgBlue) 121 | 122 | for i, genre := range genres { 123 | if i != 0 { 124 | r.Print(", ", genre.Name) 125 | } else { 126 | r.Print(genre.Name) 127 | } 128 | } 129 | print("\n") 130 | } 131 | 132 | // Prints the score with a color dependent on how high it is. 133 | func formatScore(score float64) { 134 | color.New(color.Bold).Print("📈 Score: ") 135 | 136 | if score >= 8 { 137 | color.New(color.FgHiGreen).Add(color.Bold).Println(score) 138 | return 139 | } 140 | if score >= 7 { 141 | color.New(color.FgYellow).Add(color.Bold).Println(score) 142 | return 143 | } 144 | 145 | color.New(color.FgRed).Add(color.Bold).Println(score) 146 | } 147 | -------------------------------------------------------------------------------- /utils/search.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/briandowns/spinner" 11 | "github.com/cadecuddy/sauce/types" 12 | "github.com/darenliang/jikan-go" 13 | ) 14 | 15 | // Analyzes initial trace.moe API response and handles errors. 16 | // A 3rd party MAL (MyAnimeList) API is queried to get more 17 | // detailed anime data to supplement the trace.moe data. 18 | func HandleResponse(res *http.Response, err error, s *spinner.Spinner) (types.Result, jikan.AnimeById) { 19 | if err != nil { 20 | fmt.Println("❌ Error with request") 21 | s.Stop() 22 | os.Exit(1) 23 | } 24 | defer res.Body.Close() 25 | 26 | // read body to buffer 27 | body, err := ioutil.ReadAll(res.Body) 28 | if err != nil { 29 | os.Exit(1) 30 | } 31 | 32 | // Unmarshall JSON to custom trace.moe response type 33 | var traceMoeResponse types.MoeResponse 34 | json.Unmarshal(body, &traceMoeResponse) 35 | if traceMoeResponse.Error != "" { 36 | s.Stop() 37 | fmt.Println("❌ Invalid Media") 38 | os.Exit(1) 39 | } 40 | 41 | // Query jikan API for MAL data 42 | identifiedAnime := traceMoeResponse.Result[0] 43 | malData, err := jikan.GetAnimeById(identifiedAnime.Anilist.MalID) 44 | if err != nil { 45 | s.Stop() 46 | fmt.Println("❌ Error getting MAL data") 47 | os.Exit(1) 48 | } 49 | 50 | return identifiedAnime, *malData 51 | } 52 | -------------------------------------------------------------------------------- /utils/spinner.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/briandowns/spinner" 5 | "time" 6 | ) 7 | 8 | func ConfigSpinner() *spinner.Spinner { 9 | someSet := []string{"[🥫🌸🌸🌸🌸]", "[🌸🥫🌸🌸🌸]", "[🌸🌸🥫🌸🌸]", "[🌸🌸🌸🥫🌸]", "[🌸🌸🌸🌸🥫]"} 10 | s := spinner.New(someSet, 100*time.Millisecond) 11 | s.Suffix = " 🔍 searching for sauce..." 12 | 13 | return s 14 | } 15 | -------------------------------------------------------------------------------- /utils/timestamp.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | func ConvertTimestamp(time float64) string { 6 | totalSecs := int64(time) 7 | hours := totalSecs / 3600 8 | minutes := (totalSecs % 3600) / 60 9 | seconds := totalSecs % 60 10 | 11 | var timeString string 12 | if hours != 0 { 13 | timeString = fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) 14 | } else { 15 | timeString = fmt.Sprintf("%02d:%02d", minutes, seconds) 16 | } 17 | 18 | return timeString 19 | } 20 | --------------------------------------------------------------------------------