Compare commits
4 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e57373cfa2 | ||
![]() |
27a221318e | ||
![]() |
20ab5804c6 | ||
![]() |
ac8eafd44c |
9 changed files with 460 additions and 384 deletions
|
@ -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.
|
A terminal-based YouTube video browser and player written in Go. Search for videos directly from your terminal and play them using mpv.
|
||||||
|
|
||||||
|
|
32
config/config.go
Normal file
32
config/config.go
Normal file
|
@ -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
|
||||||
|
}
|
5
go.mod
5
go.mod
|
@ -3,10 +3,9 @@ module ytgo
|
||||||
go 1.23.4
|
go 1.23.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v0.3.1
|
||||||
github.com/charmbracelet/bubbles v0.20.0
|
github.com/charmbracelet/bubbles v0.20.0
|
||||||
github.com/charmbracelet/bubbletea v1.2.4
|
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
|
google.golang.org/api v0.210.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,14 +36,12 @@ require (
|
||||||
github.com/muesli/termenv v0.15.2 // indirect
|
github.com/muesli/termenv v0.15.2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sahilm/fuzzy v0.1.1 // 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.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.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 v1.29.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||||
golang.org/x/crypto v0.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/net v0.31.0 // indirect
|
||||||
golang.org/x/oauth2 v0.24.0 // indirect
|
golang.org/x/oauth2 v0.24.0 // indirect
|
||||||
golang.org/x/sync v0.9.0 // indirect
|
golang.org/x/sync v0.9.0 // indirect
|
||||||
|
|
9
go.sum
9
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/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 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
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/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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
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-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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
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=
|
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/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 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
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/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-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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
|
7
main.go
7
main.go
|
@ -4,10 +4,17 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"ytgo/config"
|
||||||
"ytgo/tui"
|
"ytgo/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
_, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error loading configuration: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
if err := tui.Run(); err != nil {
|
if err := tui.Run(); err != nil {
|
||||||
log.Fatalf("Error running application: %v", err)
|
log.Fatalf("Error running application: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
package scraper
|
package scraper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
// "log"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ytgo/config"
|
||||||
|
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
"google.golang.org/api/youtube/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Video struct {
|
type Video struct {
|
||||||
|
@ -15,103 +20,165 @@ type Video struct {
|
||||||
URL string
|
URL string
|
||||||
Channel string
|
Channel string
|
||||||
Duration string
|
Duration string
|
||||||
|
Views string
|
||||||
|
Thumbnail string
|
||||||
UploadDate string
|
UploadDate string
|
||||||
|
ParsedDate time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated regular expressions to match the shell script
|
func formatViews(count uint64) string {
|
||||||
var (
|
switch {
|
||||||
titleRegex = regexp.MustCompile(`"title":\{"runs":\[\{"text":"([^"]+)"\}\]`)
|
case count >= 1000000000:
|
||||||
channelRegex = regexp.MustCompile(`"ownerText":\{"runs":\[\{"text":"([^"]+)"\}\]`)
|
return fmt.Sprintf("%.1fB views", float64(count)/1000000000)
|
||||||
durationRegex = regexp.MustCompile(`"lengthText":\{"accessibility":\{"accessibilityData":\{"label":"[^"]*"\}\},"simpleText":"([^"]+)"`)
|
case count >= 1000000:
|
||||||
uploadDateRegex = regexp.MustCompile(`"publishedTimeText":\{"simpleText":"([^"]+)"\}`)
|
return fmt.Sprintf("%.1fM views", float64(count)/1000000)
|
||||||
videoIDRegex = regexp.MustCompile(`watch\?v=([^"]+)`)
|
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) {
|
func FetchVideos(query string) ([]Video, error) {
|
||||||
client := &http.Client{
|
cfg, err := config.LoadConfig()
|
||||||
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)
|
|
||||||
if err != nil {
|
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
|
ctx := context.Background()
|
||||||
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")
|
youtubeService, err := youtube.NewService(ctx, option.WithAPIKey(cfg.APIKey))
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
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 {
|
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
|
var videos []Video
|
||||||
for i := 0; i < len(titles) && i < 10; i++ { // Limit to 10 results like the shell script
|
for _, item := range response.Items {
|
||||||
if i >= len(videoIDs) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
video := Video{
|
video := Video{
|
||||||
Title: unescapeHTML(titles[i][1]),
|
Title: item.Snippet.Title,
|
||||||
URL: fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoIDs[i][1]),
|
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) {
|
// Parse the upload date and store it
|
||||||
video.Channel = unescapeHTML(channels[i][1])
|
parsedDate, err := time.Parse(time.RFC3339, video.UploadDate)
|
||||||
}
|
if err == nil {
|
||||||
if i < len(durations) {
|
video.ParsedDate = parsedDate
|
||||||
video.Duration = durations[i][1]
|
|
||||||
}
|
|
||||||
if i < len(uploadDates) {
|
|
||||||
video.UploadDate = uploadDates[i][1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
videos = append(videos, video)
|
videos = append(videos, video)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(videos) == 0 {
|
// Get additional video details (duration, views) in a single request
|
||||||
return nil, fmt.Errorf("no videos found")
|
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
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
|
107
tui/image.go
107
tui/image.go
|
@ -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"
|
|
||||||
}
|
|
352
tui/tui.go
352
tui/tui.go
|
@ -2,15 +2,18 @@ package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
// "log"
|
"log"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ytgo/player"
|
||||||
|
"ytgo/scraper"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"ytgo/scraper"
|
|
||||||
"ytgo/player"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type videoItem struct {
|
type videoItem struct {
|
||||||
|
@ -18,26 +21,31 @@ type videoItem struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement list.Item for videoItem
|
// Implement list.Item for videoItem
|
||||||
func (v videoItem) Title() string { return v.Video.Title }
|
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) 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 }
|
func (v videoItem) FilterValue() string { return v.Video.Title }
|
||||||
|
|
||||||
type model struct {
|
// Add these constants for sort modes
|
||||||
searchBar textinput.Model
|
const (
|
||||||
results list.Model
|
SortByDate = iota
|
||||||
videos []scraper.Video
|
SortByViews
|
||||||
showSearch bool
|
SortByTitle
|
||||||
err error
|
SortByDuration
|
||||||
width int
|
)
|
||||||
height int
|
|
||||||
clock time.Time
|
|
||||||
isLoading bool
|
|
||||||
spinner spinner
|
|
||||||
}
|
|
||||||
|
|
||||||
type spinner struct {
|
type model struct {
|
||||||
frames []string
|
searchBar textinput.Model
|
||||||
current int
|
results list.Model
|
||||||
|
videos []scraper.Video
|
||||||
|
showSearch bool
|
||||||
|
err error
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
clock time.Time
|
||||||
|
currentSort int
|
||||||
|
sortAscending bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial model setup
|
// Initial model setup
|
||||||
|
@ -51,16 +59,13 @@ func initialModel() model {
|
||||||
results.Title = "Search Results"
|
results.Title = "Search Results"
|
||||||
|
|
||||||
return model{
|
return model{
|
||||||
searchBar: searchBar,
|
searchBar: searchBar,
|
||||||
results: results,
|
results: results,
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
clock: time.Now(),
|
clock: time.Now(),
|
||||||
width: 80, // Default width
|
width: 80, // Default width
|
||||||
height: 24, // Default height
|
height: 24, // Default height
|
||||||
spinner: spinner{
|
sortAscending: false,
|
||||||
frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
|
|
||||||
current: 0,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,8 +81,9 @@ func (m model) Init() tea.Cmd {
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
// Handle global keybindings first
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case "/":
|
case "/":
|
||||||
|
@ -91,35 +97,84 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.searchBar.Blur()
|
m.searchBar.Blur()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
case "enter":
|
// Handle context-specific keybindings
|
||||||
if m.showSearch {
|
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()
|
query := m.searchBar.Value()
|
||||||
if query == "" {
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.isLoading = true
|
// Populate the results list
|
||||||
// Return both the loading animation and the search command
|
items := make([]list.Item, len(videos))
|
||||||
return m, tea.Batch(
|
for i, v := range videos {
|
||||||
tickCmd(),
|
items[i] = videoItem{Video: v}
|
||||||
func() tea.Msg {
|
}
|
||||||
videos, err := scraper.FetchVideos(query)
|
m.videos = videos
|
||||||
if err != nil {
|
m.results.SetItems(items)
|
||||||
return searchErrorMsg{err: err}
|
m.showSearch = false
|
||||||
}
|
m.searchBar.Blur()
|
||||||
return searchResultMsg{videos: videos}
|
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
|
case "enter":
|
||||||
if i := m.results.Index(); i != -1 {
|
if i := m.results.Index(); i != -1 {
|
||||||
selectedVideo := m.videos[i]
|
selectedVideo := m.videos[i]
|
||||||
cmd := player.PlayVideo(selectedVideo.URL)
|
log.Printf("Playing video: %s", selectedVideo.URL)
|
||||||
return m, tea.ExecProcess(cmd, nil)
|
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:
|
case tea.WindowSizeMsg:
|
||||||
|
@ -127,31 +182,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.results.SetSize(msg.Width, msg.Height-3) // Leave space for search bar
|
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:
|
case time.Time:
|
||||||
if m.isLoading {
|
|
||||||
m.spinner.current = (m.spinner.current + 1) % len(m.spinner.frames)
|
|
||||||
}
|
|
||||||
m.clock = msg
|
m.clock = msg
|
||||||
return m, tickCmd()
|
return m, tickCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle updates for the active component
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
if m.showSearch {
|
if m.showSearch {
|
||||||
m.searchBar, cmd = m.searchBar.Update(msg)
|
m.searchBar, cmd = m.searchBar.Update(msg)
|
||||||
|
@ -174,16 +210,31 @@ func (m model) View() string {
|
||||||
s.WriteString(clock + "\n\n")
|
s.WriteString(clock + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show search bar with loading animation if searching
|
// Always show search bar at the top
|
||||||
searchText := "Press '/' to search"
|
searchText := "Press '/' to search"
|
||||||
if m.showSearch {
|
if m.showSearch {
|
||||||
searchText = m.searchBar.View()
|
searchText = m.searchBar.View()
|
||||||
}
|
}
|
||||||
if m.isLoading {
|
|
||||||
searchText += " " + m.spinner.frames[m.spinner.current] + " Searching..."
|
|
||||||
}
|
|
||||||
s.WriteString(searchText + "\n\n")
|
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
|
// Show results
|
||||||
s.WriteString(m.results.View())
|
s.WriteString(m.results.View())
|
||||||
|
|
||||||
|
@ -207,10 +258,153 @@ func tickCmd() tea.Cmd {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type searchResultMsg struct {
|
// Add this new method to the model
|
||||||
videos []scraper.Video
|
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 {
|
// Add this helper function
|
||||||
err error
|
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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue