aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/auth.go111
-rw-r--r--lib/image_cache.go98
-rw-r--r--lib/image_renderer.go158
-rw-r--r--lib/recommendations.go79
-rw-r--r--lib/user.go45
5 files changed, 491 insertions, 0 deletions
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
+}