diff --git a/README.md b/README.md index 8094aad..a5e2c54 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# YTGO +# YT-TUI A terminal-based YouTube video browser and player written in Go. Search for videos directly from your terminal and play them using mpv. diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 06cdce2..0000000 --- a/config/config.go +++ /dev/null @@ -1,32 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/BurntSushi/toml" -) - -type Config struct { - APIKey string `toml:"api_key"` -} - -func LoadConfig() (*Config, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("could not determine home directory: %w", err) - } - - configPath := filepath.Join(homeDir, ".config", "ytgo", "config.toml") - var config Config - if _, err := toml.DecodeFile(configPath, &config); err != nil { - return nil, fmt.Errorf("could not read config file: %w", err) - } - - if config.APIKey == "" { - return nil, fmt.Errorf("API key is missing in config file") - } - - return &config, nil -} diff --git a/go.mod b/go.mod index de58182..ca7196f 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,10 @@ module ytgo go 1.23.4 require ( - github.com/BurntSushi/toml v0.3.1 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.4 + github.com/disintegration/imaging v1.6.2 + github.com/mattn/go-sixel v0.0.5 google.golang.org/api v0.210.0 ) @@ -36,12 +37,14 @@ require ( github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/soniakeys/quant v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect golang.org/x/crypto v0.29.0 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/net v0.31.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect golang.org/x/sync v0.9.0 // indirect diff --git a/go.sum b/go.sum index e503e29..ddc24fd 100644 --- a/go.sum +++ b/go.sum @@ -5,7 +5,6 @@ cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyT cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= @@ -27,6 +26,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -81,6 +82,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8= +github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -95,6 +98,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= +github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -118,6 +123,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= diff --git a/main.go b/main.go index 712ca13..0b8f341 100644 --- a/main.go +++ b/main.go @@ -4,17 +4,10 @@ import ( "log" "os" - "ytgo/config" "ytgo/tui" ) func main() { - _, err := config.LoadConfig() - if err != nil { - log.Fatalf("Error loading configuration: %v", err) - os.Exit(1) - } - if err := tui.Run(); err != nil { log.Fatalf("Error running application: %v", err) os.Exit(1) diff --git a/scraper/scraper.go b/scraper/scraper.go index 8ff95e6..2823376 100644 --- a/scraper/scraper.go +++ b/scraper/scraper.go @@ -1,18 +1,13 @@ package scraper import ( - "context" "fmt" - "time" - - // "log" - "html" + "io" + "net/http" + "net/url" + "regexp" "strings" - - "ytgo/config" - - "google.golang.org/api/option" - "google.golang.org/api/youtube/v3" + "time" ) type Video struct { @@ -20,165 +15,103 @@ type Video struct { URL string Channel string Duration string - Views string - Thumbnail string UploadDate string - ParsedDate time.Time } -func formatViews(count uint64) string { - switch { - case count >= 1000000000: - return fmt.Sprintf("%.1fB views", float64(count)/1000000000) - case count >= 1000000: - return fmt.Sprintf("%.1fM views", float64(count)/1000000) - case count >= 1000: - return fmt.Sprintf("%.1fK views", float64(count)/1000) - default: - return fmt.Sprintf("%d views", count) - } -} - -func formatDuration(duration string) string { - // Remove PT from the start - duration = strings.TrimPrefix(duration, "PT") - - var result strings.Builder - - // Handle hours - if i := strings.Index(duration, "H"); i != -1 { - result.WriteString(duration[:i]) - result.WriteString(":") - duration = duration[i+1:] - } - - // Handle minutes - if i := strings.Index(duration, "M"); i != -1 { - minutes := duration[:i] - if len(minutes) == 1 { - result.WriteString("0") - } - result.WriteString(minutes) - result.WriteString(":") - duration = duration[i+1:] - } else if result.Len() > 0 { - result.WriteString("00:") - } - - // Handle seconds - if i := strings.Index(duration, "S"); i != -1 { - seconds := duration[:i] - if len(seconds) == 1 { - result.WriteString("0") - } - result.WriteString(seconds) - } else { - result.WriteString("00") - } - - return result.String() -} - -func formatUploadDate(uploadDate string) string { - t, err := time.Parse(time.RFC3339, uploadDate) - if err != nil { - return uploadDate - } - - now := time.Now() - diff := now.Sub(t) - days := int(diff.Hours() / 24) - - formattedDate := t.Format("02-01-2006") - - // If video is less than 30 days old, add "X days ago" - if days < 30 { - var timeAgo string - switch { - case days == 0: - hours := int(diff.Hours()) - if hours == 0 { - timeAgo = "just now" - } else { - timeAgo = fmt.Sprintf("%dh ago", hours) - } - case days == 1: - timeAgo = "1 day ago" - default: - timeAgo = fmt.Sprintf("%d days ago", days) - } - return fmt.Sprintf("%s (%s)", formattedDate, timeAgo) - } - - return formattedDate -} +// Updated regular expressions to match the shell script +var ( + titleRegex = regexp.MustCompile(`"title":\{"runs":\[\{"text":"([^"]+)"\}\]`) + channelRegex = regexp.MustCompile(`"ownerText":\{"runs":\[\{"text":"([^"]+)"\}\]`) + durationRegex = regexp.MustCompile(`"lengthText":\{"accessibility":\{"accessibilityData":\{"label":"[^"]*"\}\},"simpleText":"([^"]+)"`) + uploadDateRegex = regexp.MustCompile(`"publishedTimeText":\{"simpleText":"([^"]+)"\}`) + videoIDRegex = regexp.MustCompile(`watch\?v=([^"]+)`) +) func FetchVideos(query string) ([]Video, error) { - cfg, err := config.LoadConfig() - if err != nil { - return nil, fmt.Errorf("error loading config: %w", err) + client := &http.Client{ + Timeout: 10 * time.Second, } - ctx := context.Background() - youtubeService, err := youtube.NewService(ctx, option.WithAPIKey(cfg.APIKey)) + // Format URL similar to the shell script + searchURL := fmt.Sprintf("https://www.youtube.com/results?search_query=%s", + url.QueryEscape(strings.ReplaceAll(query, " ", "+"))) + + fmt.Printf("Fetching: %s\n", searchURL) // Debug print + + req, err := http.NewRequest("GET", searchURL, nil) if err != nil { - return nil, fmt.Errorf("error creating YouTube client: %w", err) + return nil, fmt.Errorf("error creating request: %w", err) } - // Make the search request - call := youtubeService.Search.List([]string{"snippet"}). - Q(query). - MaxResults(50). - Type("video"). - VideoDuration("any") + // Add headers to mimic a browser + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") - response, err := call.Do() + resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("error making search request: %w", err) + return nil, fmt.Errorf("error making request: %w", err) } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + content := string(body) + + // Extract information + titles := titleRegex.FindAllStringSubmatch(content, -1) + channels := channelRegex.FindAllStringSubmatch(content, -1) + durations := durationRegex.FindAllStringSubmatch(content, -1) + uploadDates := uploadDateRegex.FindAllStringSubmatch(content, -1) + videoIDs := videoIDRegex.FindAllStringSubmatch(content, -1) + + fmt.Printf("Found: %d titles, %d channels, %d durations, %d dates, %d IDs\n", + len(titles), len(channels), len(durations), len(uploadDates), len(videoIDs)) var videos []Video - for _, item := range response.Items { - video := Video{ - Title: item.Snippet.Title, - URL: fmt.Sprintf("https://www.youtube.com/watch?v=%s", item.Id.VideoId), - Channel: item.Snippet.ChannelTitle, - Thumbnail: item.Snippet.Thumbnails.Default.Url, - UploadDate: item.Snippet.PublishedAt, + for i := 0; i < len(titles) && i < 10; i++ { // Limit to 10 results like the shell script + if i >= len(videoIDs) { + break } - // Parse the upload date and store it - parsedDate, err := time.Parse(time.RFC3339, video.UploadDate) - if err == nil { - video.ParsedDate = parsedDate + video := Video{ + Title: unescapeHTML(titles[i][1]), + URL: fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoIDs[i][1]), + } + + if i < len(channels) { + video.Channel = unescapeHTML(channels[i][1]) + } + if i < len(durations) { + video.Duration = durations[i][1] + } + if i < len(uploadDates) { + video.UploadDate = uploadDates[i][1] } videos = append(videos, video) } - // Get additional video details (duration, views) in a single request - videoIds := make([]string, len(response.Items)) - for i, item := range response.Items { - videoIds[i] = item.Id.VideoId - } - - // Get video statistics - statsCall := youtubeService.Videos.List([]string{"contentDetails", "statistics"}). - Id(videoIds...) - statsResponse, err := statsCall.Do() - if err != nil { - return nil, fmt.Errorf("error fetching video details: %w", err) - } - - // Update videos with additional information - for i, stat := range statsResponse.Items { - if i < len(videos) { - videos[i].Duration = formatDuration(stat.ContentDetails.Duration) - videos[i].Views = formatViews(stat.Statistics.ViewCount) - videos[i].Title = html.UnescapeString(videos[i].Title) - videos[i].UploadDate = formatUploadDate(videos[i].UploadDate) - } + if len(videos) == 0 { + return nil, fmt.Errorf("no videos found") } return videos, nil } + +func unescapeHTML(s string) string { + replacements := map[string]string{ + "\\u0026": "&", + "\\\"": "\"", + "\\u003c": "<", + "\\u003e": ">", + """: "\"", + "'": "'", + } + + for old, new := range replacements { + s = strings.ReplaceAll(s, old, new) + } + return s +} diff --git a/thumbnail/thumbnail.go b/thumbnail/thumbnail.go new file mode 100644 index 0000000..adfcf64 --- /dev/null +++ b/thumbnail/thumbnail.go @@ -0,0 +1,107 @@ +package thumbnail + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/mattn/go-sixel" +) + +func FetchAndProcessThumbnail(url string) (string, error) { + if url == "" { + return "", fmt.Errorf("empty URL") + } + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read image data: %w", err) + } + + img, _, err := image.Decode(bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to decode image: %w", err) + } + + // Consistent thumbnail size + width := 40 + height := 15 + + img = imaging.Resize(img, width*2, height*2, imaging.Lanczos) + + // Try iTerm2 protocol first + if isITerm() { + return getITermImageSequence(img), nil + } + + // Try Sixel next + var buf bytes.Buffer + enc := sixel.NewEncoder(&buf) + err = enc.Encode(img) + if err == nil { + return buf.String(), nil + } + + // Fall back to ASCII art + return imageToASCII(img), nil +} + +func imageToASCII(img image.Image) string { + bounds := img.Bounds() + width := 40 + height := 15 + chars := []string{"█", "▓", "▒", "░", " "} + + var ascii strings.Builder + stepX := float64(bounds.Dx()) / float64(width) + stepY := float64(bounds.Dy()) / float64(height) + + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + px := int(float64(x) * stepX) + py := int(float64(y) * stepY) + c := color.GrayModel.Convert(img.At(px+bounds.Min.X, py+bounds.Min.Y)).(color.Gray) + index := int(c.Y) * (len(chars) - 1) / 255 + ascii.WriteString(chars[index]) + } + ascii.WriteString("\n") + } + + return ascii.String() +} + +func getITermImageSequence(img image.Image) string { + var buf bytes.Buffer + imaging.Encode(&buf, img, imaging.PNG) + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + + return fmt.Sprintf("\033]1337;File=inline=1;width=40ch;height=15;preserveAspectRatio=1:%s\a", encoded) +} + +func isITerm() bool { + return os.Getenv("TERM_PROGRAM") == "iTerm.app" +} \ No newline at end of file diff --git a/tui/image.go b/tui/image.go new file mode 100644 index 0000000..723923a --- /dev/null +++ b/tui/image.go @@ -0,0 +1,107 @@ +package tui + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/mattn/go-sixel" +) + +func FetchAndProcessThumbnail(url string) (string, error) { + if url == "" { + return "", fmt.Errorf("empty URL") + } + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read image data: %w", err) + } + + img, _, err := image.Decode(bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to decode image: %w", err) + } + + // Consistent thumbnail size + width := 40 + height := 15 + + img = imaging.Resize(img, width*2, height*2, imaging.Lanczos) + + // Try iTerm2 protocol first + if isITerm() { + return getITermImageSequence(img), nil + } + + // Try Sixel next + var buf bytes.Buffer + enc := sixel.NewEncoder(&buf) + err = enc.Encode(img) + if err == nil { + return buf.String(), nil + } + + // Fall back to ASCII art + return imageToASCII(img), nil +} + +func imageToASCII(img image.Image) string { + bounds := img.Bounds() + width := 40 + height := 15 + chars := []string{"█", "▓", "▒", "░", " "} + + var ascii strings.Builder + stepX := float64(bounds.Dx()) / float64(width) + stepY := float64(bounds.Dy()) / float64(height) + + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + px := int(float64(x) * stepX) + py := int(float64(y) * stepY) + c := color.GrayModel.Convert(img.At(px+bounds.Min.X, py+bounds.Min.Y)).(color.Gray) + index := int(c.Y) * (len(chars) - 1) / 255 + ascii.WriteString(chars[index]) + } + ascii.WriteString("\n") + } + + return ascii.String() +} + +func getITermImageSequence(img image.Image) string { + var buf bytes.Buffer + imaging.Encode(&buf, img, imaging.PNG) + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + + return fmt.Sprintf("\033]1337;File=inline=1;width=40ch;height=15;preserveAspectRatio=1:%s\a", encoded) +} + +func isITerm() bool { + return os.Getenv("TERM_PROGRAM") == "iTerm.app" +} \ No newline at end of file diff --git a/tui/tui.go b/tui/tui.go index b23bc5e..1b8182c 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -2,18 +2,15 @@ package tui import ( "fmt" - "log" - "sort" - "strconv" + // "log" "strings" "time" - "ytgo/player" - "ytgo/scraper" - "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "ytgo/scraper" + "ytgo/player" ) type videoItem struct { @@ -21,31 +18,26 @@ type videoItem struct { } // Implement list.Item for videoItem -func (v videoItem) Title() string { return v.Video.Title } -func (v videoItem) Description() string { - return fmt.Sprintf("%s | %s | %s | %s", v.Video.Channel, v.Video.Duration, v.Video.UploadDate, v.Video.Views) -} +func (v videoItem) Title() string { return v.Video.Title } +func (v videoItem) Description() string { return fmt.Sprintf("%s | %s | %s", v.Video.Channel, v.Video.Duration, v.Video.UploadDate) } func (v videoItem) FilterValue() string { return v.Video.Title } -// Add these constants for sort modes -const ( - SortByDate = iota - SortByViews - SortByTitle - SortByDuration -) - type model struct { - searchBar textinput.Model - results list.Model - videos []scraper.Video - showSearch bool - err error - width int - height int - clock time.Time - currentSort int - sortAscending bool + searchBar textinput.Model + results list.Model + videos []scraper.Video + showSearch bool + err error + width int + height int + clock time.Time + isLoading bool + spinner spinner +} + +type spinner struct { + frames []string + current int } // Initial model setup @@ -59,13 +51,16 @@ func initialModel() model { results.Title = "Search Results" return model{ - searchBar: searchBar, - results: results, - showSearch: true, - clock: time.Now(), - width: 80, // Default width - height: 24, // Default height - sortAscending: false, + searchBar: searchBar, + results: results, + showSearch: true, + clock: time.Now(), + width: 80, // Default width + height: 24, // Default height + spinner: spinner{ + frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + current: 0, + }, } } @@ -81,9 +76,8 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - // Handle global keybindings first switch msg.String() { - case "ctrl+c": + case "q", "ctrl+c": return m, tea.Quit case "/": @@ -97,84 +91,35 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.searchBar.Blur() } return m, nil - } - // Handle context-specific keybindings - if m.showSearch { - // When in search mode, only handle enter and let other keys go to the search input - switch msg.String() { - case "enter": + case "enter": + if m.showSearch { query := m.searchBar.Value() if query == "" { - return m, nil // Ignore empty queries - } - - // Fetch video results - videos, err := scraper.FetchVideos(query) - if err != nil { - m.err = err - log.Printf("Error fetching videos: %v", err) return m, nil } - // Populate the results list - items := make([]list.Item, len(videos)) - for i, v := range videos { - items[i] = videoItem{Video: v} - } - m.videos = videos - m.results.SetItems(items) - m.showSearch = false - m.searchBar.Blur() - return m, nil - } - } else { - // When not in search mode, handle navigation and sort keybindings - switch msg.String() { - case "q": - return m, tea.Quit - - case "enter": - if i := m.results.Index(); i != -1 { - selectedVideo := m.videos[i] - log.Printf("Playing video: %s", selectedVideo.URL) - cmd := player.PlayVideo(selectedVideo.URL) - - // Execute the command and handle any errors - if err := cmd.Start(); err != nil { - m.err = fmt.Errorf("this video URL cannot be played: %w", err) - log.Printf("Error starting video playback: %v", err) - return m, nil - } - - // Optionally, wait for the command to finish - go func() { - if err := cmd.Wait(); err != nil { - log.Printf("Error during video playback: %v", err) + m.isLoading = true + // Return both the loading animation and the search command + return m, tea.Batch( + tickCmd(), + func() tea.Msg { + videos, err := scraper.FetchVideos(query) + if err != nil { + return searchErrorMsg{err: err} } - }() - - return m, nil - } - - case "s": - m.currentSort = (m.currentSort + 1) % 4 - m.sortVideos() - return m, nil - - case "S": - m.currentSort = (m.currentSort - 1) - if m.currentSort < 0 { - m.currentSort = 3 - } - m.sortVideos() - return m, nil - - case "r": - m.sortAscending = !m.sortAscending - m.sortVideos() - return m, nil + return searchResultMsg{videos: videos} + }, + ) } + + // Handle video selection + if i := m.results.Index(); i != -1 { + selectedVideo := m.videos[i] + cmd := player.PlayVideo(selectedVideo.URL) + return m, tea.ExecProcess(cmd, nil) + } + return m, nil } case tea.WindowSizeMsg: @@ -182,12 +127,31 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.results.SetSize(msg.Width, msg.Height-3) // Leave space for search bar + case searchResultMsg: + m.isLoading = false + items := make([]list.Item, len(msg.videos)) + for i, v := range msg.videos { + items[i] = videoItem{Video: v} + } + m.videos = msg.videos + m.results.SetItems(items) + m.showSearch = false + m.searchBar.Blur() + return m, nil + + case searchErrorMsg: + m.isLoading = false + m.err = msg.err + return m, nil + case time.Time: + if m.isLoading { + m.spinner.current = (m.spinner.current + 1) % len(m.spinner.frames) + } m.clock = msg return m, tickCmd() } - // Handle updates for the active component var cmd tea.Cmd if m.showSearch { m.searchBar, cmd = m.searchBar.Update(msg) @@ -210,30 +174,15 @@ func (m model) View() string { s.WriteString(clock + "\n\n") } - // Always show search bar at the top + // Show search bar with loading animation if searching searchText := "Press '/' to search" if m.showSearch { searchText = m.searchBar.View() } - s.WriteString(searchText + "\n\n") - - // Add sort mode indicator with better visual feedback - sortModes := []string{"Date", "Views", "Title", "Duration"} - if len(m.results.Items()) > 0 { - s.WriteString("Sort ('s'/'S' to change, 'r' to reverse): ") - for i, mode := range sortModes { - arrow := "↓" - if m.sortAscending { - arrow = "↑" - } - if i == m.currentSort { - s.WriteString(fmt.Sprintf("[%s %s]", mode, arrow)) - } else { - s.WriteString(fmt.Sprintf(" %s ", mode)) - } - } - s.WriteString("\n\n") + if m.isLoading { + searchText += " " + m.spinner.frames[m.spinner.current] + " Searching..." } + s.WriteString(searchText + "\n\n") // Show results s.WriteString(m.results.View()) @@ -258,153 +207,10 @@ func tickCmd() tea.Cmd { }) } -// Add this new method to the model -func (m *model) sortVideos() { - items := m.results.Items() - switch m.currentSort { - case SortByDate: - sort.Slice(items, func(i, j int) bool { - vi := items[i].(videoItem).Video - vj := items[j].(videoItem).Video - - // Use ParsedDate for sorting - if m.sortAscending { - return vi.ParsedDate.Before(vj.ParsedDate) - } - return vi.ParsedDate.After(vj.ParsedDate) - }) - case SortByViews: - sort.Slice(items, func(i, j int) bool { - vi := items[i].(videoItem).Video - vj := items[j].(videoItem).Video - viewsI := parseViews(vi.Views) - viewsJ := parseViews(vj.Views) - if m.sortAscending { - return viewsI < viewsJ - } - return viewsI > viewsJ - }) - case SortByTitle: - sort.Slice(items, func(i, j int) bool { - if m.sortAscending { - return items[i].(videoItem).Video.Title < items[j].(videoItem).Video.Title - } - return items[i].(videoItem).Video.Title > items[j].(videoItem).Video.Title - }) - case SortByDuration: - sort.Slice(items, func(i, j int) bool { - vi := items[i].(videoItem).Video - vj := items[j].(videoItem).Video - di := parseDuration(vi.Duration) - dj := parseDuration(vj.Duration) - if m.sortAscending { - return di < dj - } - return di > dj - }) - } - m.results.SetItems(items) +type searchResultMsg struct { + videos []scraper.Video } -// Add this helper function -func parseDuration(dur string) int { - // Simple duration parser for formats like "5:30" or "1:23:45" - parts := strings.Split(dur, ":") - total := 0 - if len(parts) == 2 { - min, _ := strconv.Atoi(parts[0]) - sec, _ := strconv.Atoi(parts[1]) - total = min*60 + sec - } else if len(parts) == 3 { - hour, _ := strconv.Atoi(parts[0]) - min, _ := strconv.Atoi(parts[1]) - sec, _ := strconv.Atoi(parts[2]) - total = hour*3600 + min*60 + sec - } - return total -} - -func parseDate(date string) time.Time { - // Try parsing absolute date formats first - formats := []string{ - "2006-01-02", - "Jan 2, 2006", - "2 Jan 2006", - // Add more formats as needed - } - - for _, format := range formats { - if t, err := time.Parse(format, date); err == nil { - return t - } - } - - // If absolute date parsing fails, handle relative dates - if strings.Contains(date, "ago") { - return parseRelativeDate(date) - } - - return time.Time{} // Return zero time if parsing fails -} - -func parseRelativeDate(date string) time.Time { - now := time.Now() - - parts := strings.Fields(date) - if len(parts) < 2 { - return now - } - - num, err := strconv.Atoi(parts[0]) - if err != nil { - return now - } - - // Calculate time in the past based on the unit - switch { - case strings.HasPrefix(parts[1], "second"): - return now.Add(-time.Duration(num) * time.Second) - case strings.HasPrefix(parts[1], "minute"): - return now.Add(-time.Duration(num) * time.Minute) - case strings.HasPrefix(parts[1], "hour"): - return now.Add(-time.Duration(num) * time.Hour) - case strings.HasPrefix(parts[1], "day"): - return now.AddDate(0, 0, -num) - case strings.HasPrefix(parts[1], "week"): - return now.AddDate(0, 0, -num*7) - case strings.HasPrefix(parts[1], "month"): - return now.AddDate(0, -num, 0) - case strings.HasPrefix(parts[1], "year"): - return now.AddDate(-num, 0, 0) - default: - return now - } -} - -func parseViews(views string) int64 { - // Remove "views" and any commas - views = strings.ToLower(views) - views = strings.ReplaceAll(views, "views", "") - views = strings.ReplaceAll(views, ",", "") - views = strings.TrimSpace(views) - - // Handle K, M, B suffixes - multiplier := int64(1) - if strings.HasSuffix(views, "k") { - multiplier = 1000 - views = views[:len(views)-1] - } else if strings.HasSuffix(views, "m") { - multiplier = 1000000 - views = views[:len(views)-1] - } else if strings.HasSuffix(views, "b") { - multiplier = 1000000000 - views = views[:len(views)-1] - } - - // Convert to float to handle decimal values - if num, err := strconv.ParseFloat(views, 64); err == nil { - return int64(num * float64(multiplier)) - } - - return 0 +type searchErrorMsg struct { + err error }