From e25611bde49fe2db28a006aca3ea49eece046c5f Mon Sep 17 00:00:00 2001 From: Bobby Date: Sun, 8 Sep 2024 18:34:58 -0400 Subject: Image Rendering. Recommendations Thingy. Basic Home --- lib/auth.go | 111 ++++++++++++++++++++++++++++++++++ lib/image_cache.go | 98 ++++++++++++++++++++++++++++++ lib/image_renderer.go | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/recommendations.go | 79 +++++++++++++++++++++++++ lib/user.go | 45 ++++++++++++++ 5 files changed, 491 insertions(+) create mode 100644 lib/auth.go create mode 100644 lib/image_cache.go create mode 100644 lib/image_renderer.go create mode 100644 lib/recommendations.go create mode 100644 lib/user.go (limited to 'lib') diff --git a/lib/auth.go b/lib/auth.go new file mode 100644 index 0000000..b5bc06a --- /dev/null +++ b/lib/auth.go @@ -0,0 +1,111 @@ +package lib + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "yato/config" +) + +func GetNewCodeVerifier() (string, error) { + bytes := make([]byte, 100) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate code verifier: %w", err) + } + + encoded := base64.RawURLEncoding.EncodeToString(bytes) + + // Truncate to 128 characters + if len(encoded) > 128 { + encoded = encoded[:128] + } + + return encoded, nil +} + +func GetOAuthURL(codeVerifier string) string { + return fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=plain", + 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") + data.Set("client_id", config.MALClientID) + data.Set("client_secret", config.MALClientSecret) + data.Set("redirect_uri", config.MALRedirectURI) + data.Set("code", code) + + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + // POST request to MAL API + req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + 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() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var malConfig config.MyAnimeListConfig + var tokenResponse struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + malConfig.TokenType = tokenResponse.TokenType + malConfig.ExpiresIn = tokenResponse.ExpiresIn + malConfig.AccessToken = tokenResponse.AccessToken + malConfig.RefreshToken = tokenResponse.RefreshToken + + return &malConfig, nil +} 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 +} -- cgit v1.2.3