diff options
| -rw-r--r-- | config/colors.go | 15 | ||||
| -rw-r--r-- | config/constants.go | 4 | ||||
| -rw-r--r-- | go.mod | 7 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | lib/auth.go (renamed from utils/auth.go) | 21 | ||||
| -rw-r--r-- | lib/image_cache.go | 98 | ||||
| -rw-r--r-- | lib/image_renderer.go | 158 | ||||
| -rw-r--r-- | lib/recommendations.go | 79 | ||||
| -rw-r--r-- | lib/user.go | 45 | ||||
| -rw-r--r-- | main.go | 29 | ||||
| -rw-r--r-- | screens/home.go | 78 | ||||
| -rw-r--r-- | screens/screens.go | 8 |
12 files changed, 512 insertions, 34 deletions
diff --git a/config/colors.go b/config/colors.go new file mode 100644 index 0000000..8824511 --- /dev/null +++ b/config/colors.go @@ -0,0 +1,15 @@ +package config + +import "github.com/charmbracelet/lipgloss" + +type ColorConfig struct { + Primary lipgloss.AdaptiveColor + Text lipgloss.AdaptiveColor +} + +var ( + Colors = ColorConfig{ + Primary: lipgloss.AdaptiveColor{Light: "#2F51A2", Dark: "#2F51A2"}, + Text: lipgloss.AdaptiveColor{Light: "#F5F5F5", Dark: "#F5F5F5"}, + } +) diff --git a/config/constants.go b/config/constants.go index 47c3ab1..d04a8aa 100644 --- a/config/constants.go +++ b/config/constants.go @@ -8,11 +8,15 @@ import ( var ( AppName = "yato" + PrettyAppName = "Yato" Version = "0.1.0" MALOAuthBaseURL = "https://myanimelist.net/v1/oauth2/authorize" MALClientID string MALClientSecret string MALRedirectURI = "http://localhost:42069/authenticate" + MALAPIBaseURL = "https://api.myanimelist.net/v2" + JikanAPIBaseURL = "https://api.jikan.moe/v4" + ConfigDir, _ = os.UserConfigDir() ) // These variables will be set by the linker during build @@ -4,12 +4,14 @@ go 1.21.4 require ( github.com/charmbracelet/bubbletea v1.1.0 + github.com/charmbracelet/lipgloss v0.13.0 + golang.org/x/image v0.20.0 golang.org/x/term v0.24.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/lipgloss v0.13.0 // indirect github.com/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -23,6 +25,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.3.8 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/text v0.18.0 // indirect ) @@ -27,6 +27,8 @@ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1n github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= +golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -39,6 +41,8 @@ golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/utils/auth.go b/lib/auth.go index 7745237..b5bc06a 100644 --- a/utils/auth.go +++ b/lib/auth.go @@ -1,4 +1,4 @@ -package utils +package lib import ( "crypto/rand" @@ -8,6 +8,8 @@ import ( "io" "net/http" "net/url" + "os/exec" + "runtime" "strings" "yato/config" ) @@ -34,6 +36,23 @@ func GetOAuthURL(codeVerifier string) string { config.MALOAuthBaseURL, config.MALClientID, config.MALRedirectURI, codeVerifier) } +func OpenBrowser(url string) error { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + + return err +} + func ExchangeToken(code, codeVerifier string) (*config.MyAnimeListConfig, error) { data := url.Values{} data.Set("grant_type", "authorization_code") diff --git a/lib/image_cache.go b/lib/image_cache.go new file mode 100644 index 0000000..58cd09b --- /dev/null +++ b/lib/image_cache.go @@ -0,0 +1,98 @@ +package lib + +import ( + "fmt" + "image" + "image/jpeg" + "io" + "net/http" + "os" + "path/filepath" + "yato/config" +) + +// ImageCache handles caching and retrieving images +type ImageCache struct { + cacheDir string +} + +// NewImageCache creates a new ImageCache +func NewImageCache() *ImageCache { + cacheDir := filepath.Join(config.ConfigDir, config.AppName, "cache") + return &ImageCache{cacheDir: cacheDir} +} + +// GetImage retrieves an image, either from cache or by downloading it +func (c *ImageCache) GetImage(mediaType string, malID int, size string, url string) (image.Image, error) { + cachePath := c.getCachePath(mediaType, malID, size) + + // Check if the image is already cached + if img, err := c.loadFromCache(cachePath); err == nil { + return img, nil + } + + // If not cached, download and cache the image + return c.downloadAndCache(url, cachePath) +} + +func (c *ImageCache) getCachePath(mediaType string, malID int, size string) string { + return filepath.Join(c.cacheDir, mediaType, fmt.Sprintf("%d", malID), size+".jpg") +} + +func (c *ImageCache) loadFromCache(path string) (image.Image, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, err := jpeg.Decode(file) + if err != nil { + return nil, err + } + + return img, nil +} + +func (c *ImageCache) downloadAndCache(url, cachePath string) (image.Image, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download image: %s", resp.Status) + } + + // Ensure the cache directory exists + if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil { + return nil, err + } + + // Create the cache file + cacheFile, err := os.Create(cachePath) + if err != nil { + return nil, err + } + defer cacheFile.Close() + + // Download and write to cache file + _, err = io.Copy(cacheFile, resp.Body) + if err != nil { + return nil, err + } + + // Reset file pointer and decode the image + _, err = cacheFile.Seek(0, 0) + if err != nil { + return nil, err + } + + img, err := jpeg.Decode(cacheFile) + if err != nil { + return nil, err + } + + return img, nil +} diff --git a/lib/image_renderer.go b/lib/image_renderer.go new file mode 100644 index 0000000..03a8059 --- /dev/null +++ b/lib/image_renderer.go @@ -0,0 +1,158 @@ +package lib + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "os" + "strings" + + "golang.org/x/image/draw" +) + +type ImageRenderer struct { + method string +} + +func NewImageRenderer() *ImageRenderer { + method := determineRenderMethod() + return &ImageRenderer{method: method} +} + +func determineRenderMethod() string { + if os.Getenv("TERM") == "xterm-kitty" { + return "kitty" + } else if os.Getenv("TERM_PROGRAM") == "iTerm.app" { + return "iterm2" + } else if os.Getenv("TERM") == "xterm-256color" && os.Getenv("VTE_VERSION") != "" { + return "sixel" + } + return "none" +} + +func (r *ImageRenderer) RenderImage(img image.Image, width, height int) string { + switch r.method { + case "kitty": + return r.renderKitty(img, width, height) + case "iterm2": + return r.renderITerm2(img, width, height) + case "sixel": + return r.renderSixel(img, width, height) + case "ascii": + return r.renderASCII(img, width, height) + default: + return "" + } +} + +func (r *ImageRenderer) renderKitty(img image.Image, width, height int) string { + resized := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.NearestNeighbor.Scale(resized, resized.Rect, img, img.Bounds(), draw.Over, nil) + + var buf bytes.Buffer + png.Encode(&buf, resized) + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + + // Split the encoded data into chunks + const chunkSize = 4096 + chunks := make([]string, 0, (len(encoded)+chunkSize-1)/chunkSize) + for i := 0; i < len(encoded); i += chunkSize { + end := i + chunkSize + if end > len(encoded) { + end = len(encoded) + } + chunks = append(chunks, encoded[i:end]) + } + + // Build the Kitty graphics protocol command + var result strings.Builder + for i, chunk := range chunks { + if i == 0 { + result.WriteString(fmt.Sprintf("\033_Ga=T,f=100,s=%d,v=%d,m=1;", width, height)) + } else { + result.WriteString("\033_Gm=1;") + } + result.WriteString(chunk) + result.WriteString("\033\\") + } + + // Final chunk + result.WriteString("\033_Gm=0;\033\\") + + return result.String() +} + +func (r *ImageRenderer) renderITerm2(img image.Image, width, height int) string { + // Implement iTerm2 inline image protocol + var buf bytes.Buffer + jpeg.Encode(&buf, img, nil) + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + return fmt.Sprintf("\033]1337;File=inline=1;width=%dpx;height=%dpx:%s\a", width, height, encoded) +} + +func (r *ImageRenderer) renderSixel(img image.Image, width, height int) string { + resized := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.NearestNeighbor.Scale(resized, resized.Rect, img, img.Bounds(), draw.Over, nil) + + // Convert to Sixel + var sb strings.Builder + sb.WriteString("\033Pq") // Start Sixel sequence + sb.WriteString("\"1;1;") // Set color mode and aspect ratio + sb.WriteString(fmt.Sprintf("%d;%d", width, height)) + sb.WriteString("\n") + + // Simple color quantization (this can be improved) + palette := make(map[color.Color]int) + colorIndex := 0 + + for y := 0; y < height; y++ { + sixelRow := make([]int, width) + for x := 0; x < width; x++ { + c := resized.At(x, y) + if _, exists := palette[c]; !exists { + palette[c] = colorIndex + colorIndex++ + r, g, b, _ := c.RGBA() + sb.WriteString(fmt.Sprintf("#%d;2;%d;%d;%d", palette[c], r>>8, g>>8, b>>8)) + } + sixelRow[x] = palette[c] + } + + // Encode sixel data + for i := 0; i < 6; i++ { + for _, colorIdx := range sixelRow { + sb.WriteByte(byte('?' + ((colorIdx >> i) & 1))) + } + sb.WriteByte('-') + } + sb.WriteByte('\n') + } + + sb.WriteString("\033\\") // End Sixel sequence + return sb.String() +} + +func (r *ImageRenderer) renderASCII(img image.Image, width, height int) string { + // Implement a simple ASCII art renderer + // This is a very basic implementation and can be improved + bounds := img.Bounds() + ascii := "" + for y := bounds.Min.Y; y < bounds.Max.Y; y += height / 10 { + for x := bounds.Min.X; x < bounds.Max.X; x += width / 20 { + c := img.At(x, y) + r, g, b, _ := c.RGBA() + avg := (r + g + b) / 3 + if avg > 32768 { + ascii += " " + } else { + ascii += "#" + } + } + ascii += "\n" + } + return ascii +} diff --git a/lib/recommendations.go b/lib/recommendations.go new file mode 100644 index 0000000..6875a1f --- /dev/null +++ b/lib/recommendations.go @@ -0,0 +1,79 @@ +package lib + +import ( + "encoding/json" + "fmt" + "net/http" + "yato/config" +) + +type Recommendation struct { + MALId string `json:"mal_id"` + URL string `json:"url"` + Entry []struct { + MALId int `json:"mal_id"` + URL string `json:"url"` + Images struct { + JPG struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + LargeImageURL string `json:"large_image_url"` + } `json:"jpg"` + WebP struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + LargeImageURL string `json:"large_image_url"` + } `json:"webp"` + } `json:"images"` + Title string `json:"title"` + } `json:"entry"` + Content string `json:"content"` + Date string `json:"date"` + User struct { + URL string `json:"url"` + Username string `json:"username"` + } `json:"user"` +} + +type recommendationsResponse struct { + Pagination struct { + LastVisiblePage int `json:"last_visible_page"` + HasNextPage bool `json:"has_next_page"` + } `json:"pagination"` + Data []Recommendation `json:"data"` +} + +func getRecentRecommendations(mediaType string) ([]Recommendation, error) { + url := fmt.Sprintf("%s/recommendations/%s", config.JikanAPIBaseURL, mediaType) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response recommendationsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return response.Data, nil +} + +func GetRecentAnimeRecommendations() ([]Recommendation, error) { + return getRecentRecommendations("anime") +} + +func GetRecentMangaRecommendations() ([]Recommendation, error) { + return getRecentRecommendations("manga") +} diff --git a/lib/user.go b/lib/user.go new file mode 100644 index 0000000..cbfde00 --- /dev/null +++ b/lib/user.go @@ -0,0 +1,45 @@ +package lib + +import ( + "encoding/json" + "fmt" + "net/http" + "yato/config" +) + +type MALUser struct { + ID int `json:"id"` + Name string `json:"name"` + Birthday string `json:"birthday"` + Location string `json:"location"` + JoinedAt string `json:"joined_at"` + Picture string `json:"picture"` +} + +func CurrentUser() (*MALUser, error) { + var user MALUser + + req, err := http.NewRequest("GET", "https://api.myanimelist.net/v2/users/@me", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.GetConfig().MyAnimeList.AccessToken)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &user, nil +} @@ -6,11 +6,9 @@ import ( "log" "net/http" "os" - "os/exec" - "runtime" "yato/config" + "yato/lib" "yato/screens" - "yato/utils" tea "github.com/charmbracelet/bubbletea" ) @@ -50,12 +48,12 @@ func StartApp() { func StartOAuthFlow() { var err error - codeVerifier, err = utils.GetNewCodeVerifier() + codeVerifier, err = lib.GetNewCodeVerifier() if err != nil { log.Fatalf("failed to generate code verifier: %s", err) } - url := utils.GetOAuthURL(codeVerifier) + url := lib.GetOAuthURL(codeVerifier) server := &http.Server{Addr: ":42069"} http.HandleFunc("/authenticate", handleOAuthCallback) @@ -66,7 +64,7 @@ func StartOAuthFlow() { } }() - if err := openBrowser(url); err != nil { + if err := lib.OpenBrowser(url); err != nil { log.Printf("failed to open browser: %v. Visit %s in your browser to authenticate.", err, url) } @@ -89,7 +87,7 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { return } - malConfig, err := utils.ExchangeToken(code, codeVerifier) + malConfig, err := lib.ExchangeToken(code, codeVerifier) if err != nil { errorChan <- fmt.Errorf("failed to exchange token: %w", err) http.Error(w, "failed to exchange token", http.StatusInternalServerError) @@ -106,20 +104,3 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Authentication successful! You can now close this tab.")) authorizationChan <- struct{}{} } - -func openBrowser(url string) error { - var err error - - switch runtime.GOOS { - case "linux": - err = exec.Command("xdg-open", url).Start() - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - - return err -} diff --git a/screens/home.go b/screens/home.go index 64e4705..fa88fb5 100644 --- a/screens/home.go +++ b/screens/home.go @@ -1,12 +1,33 @@ package screens -import tea "github.com/charmbracelet/bubbletea" +import ( + "fmt" + "yato/config" + "yato/lib" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) type HomeScreen struct { + RecentAnimeRecommendations []lib.Recommendation + RecentMangaRecommendations []lib.Recommendation + imageCache *lib.ImageCache + imageRenderer *lib.ImageRenderer } func homeScreen() tea.Model { - return HomeScreen{} + recentAnimeRecommendations, _ := lib.GetRecentAnimeRecommendations() + recentMangaRecommendations, _ := lib.GetRecentMangaRecommendations() + imageCache := lib.NewImageCache() + imageRenderer := lib.NewImageRenderer() + + return HomeScreen{ + RecentAnimeRecommendations: recentAnimeRecommendations, + RecentMangaRecommendations: recentMangaRecommendations, + imageCache: imageCache, + imageRenderer: imageRenderer, + } } func (h HomeScreen) Init() tea.Cmd { @@ -15,7 +36,7 @@ func (h HomeScreen) Init() tea.Cmd { func (h HomeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { - if msg.Type == tea.KeyCtrlC { + if msg.String() == "q" { return h, tea.Quit } } @@ -24,5 +45,54 @@ func (h HomeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (h HomeScreen) View() string { - return "Home" + w := lipgloss.Width + + // Top bar, Content, Status bar + topBarStyle := lipgloss.NewStyle(). + Foreground(config.Colors.Text). + Background(config.Colors.Primary) + + mainText := topBarStyle.Padding(0, 0, 0, 1).Render(config.PrettyAppName + " | [H]ome | [A]nime | [M]anga | [S]earch | [C]ommunity | [P]rofile | [O]ptions | [Q]uit") + userText := topBarStyle.Padding(0, 1, 0, 0).Render("User: " + globals.CurrentUser.Name + " | [L]ogout") + separator := topBarStyle.Width(globals.width - w(mainText) - w(userText)).Render("") + + topBar := lipgloss.JoinHorizontal( + lipgloss.Top, + mainText, + separator, + userText, + ) + + content := "" + // Top 5 recommendations + for i, rec := range h.RecentAnimeRecommendations { + if i == 5 { + break + } + content += h.renderRecommendation("anime", rec) + } + + for i, rec := range h.RecentMangaRecommendations { + if i == 5 { + break + } + content += h.renderRecommendation("manga", rec) + } + + return lipgloss.JoinVertical( + lipgloss.Top, + topBar, + content, + ) + +} + +func (h HomeScreen) renderRecommendation(mediaType string, rec lib.Recommendation) string { + img, err := h.imageCache.GetImage(mediaType, rec.Entry[0].MALId, "small", rec.Entry[0].Images.JPG.SmallImageURL) + if err != nil { + return fmt.Sprintf("%s -> %s\n", rec.Entry[0].Title, rec.Entry[1].Title) + } + + renderedImage := h.imageRenderer.RenderImage(img, 20, 30) + return fmt.Sprintf("%s%s -> %s\n", renderedImage, rec.Entry[0].Title, rec.Entry[1].Title) } diff --git a/screens/screens.go b/screens/screens.go index 1675ec7..f04878f 100644 --- a/screens/screens.go +++ b/screens/screens.go @@ -2,6 +2,7 @@ package screens import ( "os" + "yato/lib" tea "github.com/charmbracelet/bubbletea" "golang.org/x/term" @@ -12,8 +13,9 @@ type ScreenSwitcher struct { } type Globals struct { - width int - height int + width int + height int + CurrentUser *lib.MALUser } var globals Globals @@ -65,5 +67,7 @@ func Initialize() tea.Model { globals.width = width globals.height = height + globals.CurrentUser, _ = lib.CurrentUser() + return screen() } |
