diff --git a/README.md b/README.md index a5e2c54..8094aad 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# YT-TUI +# YTGO 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 new file mode 100644 index 0000000..06cdce2 --- /dev/null +++ b/config/config.go @@ -0,0 +1,32 @@ +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 ca7196f..de58182 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,9 @@ 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 ) @@ -37,14 +36,12 @@ 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 ddc24fd..e503e29 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,7 @@ 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= @@ -26,8 +27,6 @@ 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= @@ -82,8 +81,6 @@ 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= @@ -98,8 +95,6 @@ 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= @@ -123,8 +118,6 @@ 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 0b8f341..712ca13 100644 --- a/main.go +++ b/main.go @@ -4,10 +4,17 @@ 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 2823376..8ff95e6 100644 --- a/scraper/scraper.go +++ b/scraper/scraper.go @@ -1,13 +1,18 @@ package scraper import ( + "context" "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strings" "time" + + // "log" + "html" + "strings" + + "ytgo/config" + + "google.golang.org/api/option" + "google.golang.org/api/youtube/v3" ) type Video struct { @@ -15,103 +20,165 @@ type Video struct { URL string Channel string Duration string + Views string + Thumbnail string UploadDate string + ParsedDate time.Time } -// 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 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 +} func FetchVideos(query string) ([]Video, error) { - client := &http.Client{ - Timeout: 10 * time.Second, - } - - // 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) + cfg, err := config.LoadConfig() if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) + return nil, fmt.Errorf("error loading config: %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) + ctx := context.Background() + youtubeService, err := youtube.NewService(ctx, option.WithAPIKey(cfg.APIKey)) if err != nil { - return nil, fmt.Errorf("error making request: %w", err) + return nil, fmt.Errorf("error creating YouTube client: %w", err) } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + // Make the search request + call := youtubeService.Search.List([]string{"snippet"}). + Q(query). + MaxResults(50). + Type("video"). + VideoDuration("any") + + response, err := call.Do() if err != nil { - return nil, fmt.Errorf("error reading response: %w", err) + return nil, fmt.Errorf("error making search request: %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 i := 0; i < len(titles) && i < 10; i++ { // Limit to 10 results like the shell script - if i >= len(videoIDs) { - break - } - + for _, item := range response.Items { video := Video{ - Title: unescapeHTML(titles[i][1]), - URL: fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoIDs[i][1]), + 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, } - 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] + // Parse the upload date and store it + parsedDate, err := time.Parse(time.RFC3339, video.UploadDate) + if err == nil { + video.ParsedDate = parsedDate } videos = append(videos, video) } - if len(videos) == 0 { - return nil, fmt.Errorf("no videos found") + // 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) + } } 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 deleted file mode 100644 index adfcf64..0000000 --- a/thumbnail/thumbnail.go +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 723923a..0000000 --- a/tui/image.go +++ /dev/null @@ -1,107 +0,0 @@ -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 1b8182c..b23bc5e 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -2,15 +2,18 @@ package tui import ( "fmt" - // "log" + "log" + "sort" + "strconv" "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 { @@ -18,26 +21,31 @@ 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", v.Video.Channel, v.Video.Duration, v.Video.UploadDate) } +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) FilterValue() string { return v.Video.Title } -type model struct { - searchBar textinput.Model - results list.Model - videos []scraper.Video - showSearch bool - err error - width int - height int - clock time.Time - isLoading bool - spinner spinner -} +// Add these constants for sort modes +const ( + SortByDate = iota + SortByViews + SortByTitle + SortByDuration +) -type spinner struct { - frames []string - current int +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 } // Initial model setup @@ -51,16 +59,13 @@ 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 - spinner: spinner{ - frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, - current: 0, - }, + searchBar: searchBar, + results: results, + showSearch: true, + clock: time.Now(), + width: 80, // Default width + height: 24, // Default height + sortAscending: false, } } @@ -76,8 +81,9 @@ 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 "q", "ctrl+c": + case "ctrl+c": return m, tea.Quit case "/": @@ -91,35 +97,84 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.searchBar.Blur() } return m, nil + } - case "enter": - if m.showSearch { + // 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": 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 } - 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} - }, - ) + // 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 - // 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) + 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) + } + }() + + 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 m, nil } case tea.WindowSizeMsg: @@ -127,31 +182,12 @@ 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) @@ -174,16 +210,31 @@ func (m model) View() string { s.WriteString(clock + "\n\n") } - // Show search bar with loading animation if searching + // Always show search bar at the top 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") + // 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") + } + // Show results s.WriteString(m.results.View()) @@ -207,10 +258,153 @@ func tickCmd() tea.Cmd { }) } -type searchResultMsg struct { - videos []scraper.Video +// 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 searchErrorMsg struct { - err error +// 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 }