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 c323031..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= diff --git a/scraper/scraper.go b/scraper/scraper.go index 8d58b2a..8ff95e6 100644 --- a/scraper/scraper.go +++ b/scraper/scraper.go @@ -23,6 +23,7 @@ type Video struct { Views string Thumbnail string UploadDate string + ParsedDate time.Time } func formatViews(count uint64) string { @@ -145,6 +146,13 @@ func FetchVideos(query string) ([]Video, error) { Thumbnail: item.Snippet.Thumbnails.Default.Url, UploadDate: item.Snippet.PublishedAt, } + + // 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) } diff --git a/tui/tui.go b/tui/tui.go index ce9b53b..d4e3d93 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -3,14 +3,17 @@ package tui import ( "fmt" "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,19 +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 | %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 | %s", v.Video.Channel, v.Video.Duration, v.Video.UploadDate, v.Video.Views) +} 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 + 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 @@ -44,12 +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 + searchBar: searchBar, + results: results, + showSearch: true, + clock: time.Now(), + width: 80, // Default width + height: 24, // Default height + sortAscending: false, } } @@ -65,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 "/": @@ -80,9 +97,13 @@ 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 @@ -105,15 +126,39 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.results.SetItems(items) m.showSearch = false m.searchBar.Blur() - } else { - // Handle video selection + 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] cmd := player.PlayVideo(selectedVideo.URL) return m, tea.ExecProcess(cmd, 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: @@ -126,6 +171,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tickCmd() } + // Handle updates for the active component var cmd tea.Cmd if m.showSearch { m.searchBar, cmd = m.searchBar.Update(msg) @@ -155,6 +201,24 @@ func (m model) View() string { } 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()) @@ -177,3 +241,154 @@ func tickCmd() tea.Cmd { return t }) } + +// 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) +} + +// 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 +}