diff --git a/go.sum b/go.sum index c323031..ddc24fd 100644 --- a/go.sum +++ b/go.sum @@ -26,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= @@ -80,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= @@ -94,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= @@ -117,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/scraper/scraper.go b/scraper/scraper.go index 71e5730..2823376 100644 --- a/scraper/scraper.go +++ b/scraper/scraper.go @@ -1,15 +1,13 @@ package scraper import ( - "context" "fmt" - "time" - // "log" - "html" + "io" + "net/http" + "net/url" + "regexp" "strings" - - "google.golang.org/api/option" - "google.golang.org/api/youtube/v3" + "time" ) type Video struct { @@ -17,155 +15,103 @@ type Video struct { URL string Channel string Duration string - Views string - Thumbnail string UploadDate string } -// Replace with your actual API key -const API_KEY = "AIzaSyAzsihRkp8mYTOXLOkVN09yTqld9TJ4Nts" - -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) { - ctx := context.Background() - youtubeService, err := youtube.NewService(ctx, option.WithAPIKey(API_KEY)) - if err != nil { - return nil, fmt.Errorf("error creating YouTube client: %w", err) + client := &http.Client{ + Timeout: 10 * time.Second, } - // Make the search request - call := youtubeService.Search.List([]string{"snippet"}). - Q(query). - MaxResults(50). - Type("video"). - VideoDuration("any") + // Format URL similar to the shell script + searchURL := fmt.Sprintf("https://www.youtube.com/results?search_query=%s", + url.QueryEscape(strings.ReplaceAll(query, " ", "+"))) - response, err := call.Do() + fmt.Printf("Fetching: %s\n", searchURL) // Debug print + + req, err := http.NewRequest("GET", searchURL, nil) if err != nil { - return nil, fmt.Errorf("error making search request: %w", err) + return nil, fmt.Errorf("error creating request: %w", err) } + // 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") + + resp, err := client.Do(req) + if err != nil { + 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 } + + 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 ce9b53b..1b8182c 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -2,7 +2,7 @@ package tui import ( "fmt" - "log" + // "log" "strings" "time" @@ -19,7 +19,7 @@ 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) 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 } type model struct { @@ -31,6 +31,13 @@ type model struct { width int height int clock time.Time + isLoading bool + spinner spinner +} + +type spinner struct { + frames []string + current int } // Initial model setup @@ -50,6 +57,10 @@ func initialModel() model { clock: time.Now(), width: 80, // Default width height: 24, // Default height + spinner: spinner{ + frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + current: 0, + }, } } @@ -85,33 +96,28 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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() - } else { - // 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) - } + 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 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 } @@ -121,7 +127,27 @@ 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() } @@ -148,11 +174,14 @@ 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() } + if m.isLoading { + searchText += " " + m.spinner.frames[m.spinner.current] + " Searching..." + } s.WriteString(searchText + "\n\n") // Show results @@ -177,3 +206,11 @@ func tickCmd() tea.Cmd { return t }) } + +type searchResultMsg struct { + videos []scraper.Video +} + +type searchErrorMsg struct { + err error +}