diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..0d25120
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,36 @@
+name: build
+
+on:
+ push:
+ tags:
+ - "master"
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ name: GoReleaser build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: stable
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v5
+ with:
+ distribution: goreleaser
+ version: latest
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Upload assets
+ uses: actions/upload-artifact@v4
+ with:
+ name: qtg
+ path: dist/*
\ No newline at end of file
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..3d27a2d
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,18 @@
+name: lint
+
+on: [ push, pull_request ]
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.22"
+ cache: false
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v4
+ with:
+ version: "latest"
+ only-new-issues: true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c01e563
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,79 @@
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+qtg
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..69be97b
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,39 @@
+run:
+ timeout: 5m
+
+linters:
+ disable-all: true
+ enable:
+ - bodyclose
+ - deadcode
+ - depguard
+ - dogsled
+ - errcheck
+ - exportloopref
+ - gochecknoinits
+ - goconst
+ - gocritic
+ - gofmt
+ - goprintffuncname
+ - gosimple
+ - govet
+ - ineffassign
+ - misspell
+ - nakedret
+ - noctx
+ - nolintlint
+ - rowserrcheck
+ - staticcheck
+ - structcheck
+ - stylecheck
+ - typecheck
+ - unconvert
+ - unparam
+ - unused
+ - varcheck
+ - whitespace
+ - wastedassign
+ - nilerr
+ - godot
+ - godox
+ - goimports
\ No newline at end of file
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 0000000..cbc4fed
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,41 @@
+project_name: "qtg"
+
+before:
+ hooks:
+ - go mod tidy
+ - go mod download
+
+builds:
+ - main: ./main.go
+ binary: qtg
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ - freebsd
+ - openbsd
+ - netbsd
+ goarch:
+ - amd64
+ - arm64
+ - "386"
+ - arm
+ goarm:
+ - "7"
+ ignore:
+ - goos: windows
+ goarch: arm64
+ - goos: windows
+ goarm: "7"
+
+archives:
+ - format_overrides:
+ - goos: windows
+ format: zip
+
+release:
+ github:
+ owner: oustn
+ name: qtg
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..35410ca
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/QingtFMDownloader.iml b/.idea/QingtFMDownloader.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/QingtFMDownloader.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..86be08b
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..f696fe2
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2951b3c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,4 @@
+# QTG
+
+1. 抓包拿到 token & id
+2. 搜索 & 下载
\ No newline at end of file
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644
index 0000000..aa8204b
--- /dev/null
+++ b/cmd/root.go
@@ -0,0 +1,55 @@
+package cmd
+
+import (
+ "fmt"
+ teaui "github.com/oustn/qtg/internal/ui"
+ "log"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/oustn/qtg/internal/config"
+ "github.com/spf13/cobra"
+)
+
+var rootCmd = &cobra.Command{
+ Use: "qtg",
+ Short: "一个有趣的蜻蜓 FM 下载器",
+ Version: "0.0.1",
+ Args: cobra.MaximumNArgs(0),
+ Run: func(cmd *cobra.Command, args []string) {
+ cfg, path, err := config.ParseConfig()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if cfg.Settings.EnableLogging {
+ f, err := tea.LogToFile("debug.log", "debug")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ defer func() {
+ if err = f.Close(); err != nil {
+ log.Fatal(err)
+ }
+ }()
+ }
+
+ //m := ui.NewQ(&cfg, path)
+ m := teaui.NewRenderer(&cfg, path)
+ p := tea.NewProgram(m)
+ if _, err := p.Run(); err != nil {
+ log.Fatal("应用打开失败", err)
+ }
+ },
+}
+
+// Execute runs the root command and starts the application.
+func Execute() {
+ rootCmd.AddCommand(updateCmd)
+
+ if err := rootCmd.Execute(); err != nil {
+ _, _ = fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/update.go b/cmd/update.go
new file mode 100644
index 0000000..70eea64
--- /dev/null
+++ b/cmd/update.go
@@ -0,0 +1,28 @@
+package cmd
+
+import (
+ "log"
+ "os"
+ "os/exec"
+
+ "github.com/spf13/cobra"
+)
+
+var updateCmd = &cobra.Command{
+ Use: "update",
+ Short: "更新 QTG 到最新版本",
+ Long: `更新 QTG 到最新版本.`,
+ Run: func(cmd *cobra.Command, args []string) {
+ updateCommand := exec.Command("bash", "-c", "curl -sfL https://raw.githubusercontent.com/oustn/qtg-go/master/install.sh | sh")
+ updateCommand.Stdin = os.Stdin
+ updateCommand.Stdout = os.Stdout
+ updateCommand.Stderr = os.Stderr
+
+ err := updateCommand.Run()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ os.Exit(0)
+ },
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..468e953
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,62 @@
+module github.com/oustn/qtg
+
+go 1.22
+
+require (
+ github.com/Sorrow446/go-mp4tag v0.0.0-20240130220823-68ce31d53e37
+ github.com/abema/go-mp4 v1.2.0
+ github.com/cavaliergopher/grab/v3 v3.0.1
+ github.com/charmbracelet/bubbles v0.18.0
+ github.com/charmbracelet/bubbletea v0.25.0
+ github.com/charmbracelet/lipgloss v0.9.1
+ github.com/go-zoox/fetch v1.8.1
+ github.com/lucasb-eyer/go-colorful v1.2.0
+ github.com/muesli/reflow v0.3.0
+ github.com/spf13/cobra v1.8.0
+ github.com/spf13/viper v1.18.2
+ github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
+ github.com/deckarep/golang-set v1.8.0 // indirect
+ github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/go-zoox/core-utils v1.2.11 // indirect
+ github.com/go-zoox/headers v1.0.6 // indirect
+ github.com/google/uuid v1.4.0 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-isatty v0.0.19 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.15.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.1.0 // indirect
+ github.com/rivo/uniseg v0.4.6 // indirect
+ github.com/sagikazarmark/locafero v0.4.0 // indirect
+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+ github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
+ github.com/spf13/afero v1.11.0 // indirect
+ github.com/spf13/cast v1.6.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/tidwall/gjson v1.14.4 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ go.uber.org/atomic v1.9.0 // indirect
+ go.uber.org/multierr v1.9.0 // indirect
+ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sync v0.5.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ golang.org/x/term v0.15.0 // indirect
+ golang.org/x/text v0.14.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..adfbdc5
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,166 @@
+github.com/Sorrow446/go-mp4tag v0.0.0-20240130220823-68ce31d53e37 h1:6X6U2D53ITfDGiyGN+sOVm/iFveFHrFRS7icGJ+u88M=
+github.com/Sorrow446/go-mp4tag v0.0.0-20240130220823-68ce31d53e37/go.mod h1:l5rVvaRUrCot83416D6xggKCeFZQAXcv02tnJslG26s=
+github.com/abema/go-mp4 v1.2.0 h1:gi4X8xg/m179N/J15Fn5ugywN9vtI6PLk6iLldHGLAk=
+github.com/abema/go-mp4 v1.2.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4=
+github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
+github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
+github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
+github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
+github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
+github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
+github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4=
+github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/go-zoox/core-utils v1.2.11 h1:3h8P4d+P1XTEzi6M68CywUfy4p8WEZOFuWME8uIYJJ4=
+github.com/go-zoox/core-utils v1.2.11/go.mod h1:Y6izFcxuELrkOen5mTQccCJxJqqPJaZV5dQtUMBdkBM=
+github.com/go-zoox/fetch v1.8.1 h1:Zh2yW/loXSoSebKZ1UkKKMpH/E51HtFb9L0zpr+2R2I=
+github.com/go-zoox/fetch v1.8.1/go.mod h1:RaYJe2EZ/4M9zUyJl9x8HhMkQ/4HfxvdqYVkRp8IY1k=
+github.com/go-zoox/headers v1.0.6 h1:LJvVaqs6d+QUvV0sNU8qHFkeyQlECu0mJau1nVFsEQU=
+github.com/go-zoox/headers v1.0.6/go.mod h1:WEgEbewswEw4n4qS1iG68Kn/vOQVCAKGwwuZankc6so=
+github.com/go-zoox/testify v1.0.0 h1:zXuj+JMcudM/dWk8HgMfCKpGYDcyHbTUBGxH35SGubU=
+github.com/go-zoox/testify v1.0.0/go.mod h1:6+UZ2gOcwcnUvR5lclGRnLrE3/mLoQMAGExjrZgs3aA=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
+github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+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=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
+github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
+github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
+github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
+github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
+github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
+github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
+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=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365 h1:uy0xSeqOW3InVwbgqDkE00h2InGiJ1jp5BLu4k0ax8o=
+github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 h1:OXcKh35JaYsGMRzpvFkLv/MEyPuL49CThT1pZ8aSml4=
+github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
+golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
+golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
+golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
+gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..ecab1fc
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,395 @@
+#!/bin/sh
+set -e
+# Code generated by godownloader on 2024-03-21T08:49:26Z. DO NOT EDIT.
+#
+
+usage() {
+ this=$1
+ cat </dev/null
+}
+echoerr() {
+ echo "$@" 1>&2
+}
+log_prefix() {
+ echo "$0"
+}
+_logp=6
+log_set_priority() {
+ _logp="$1"
+}
+log_priority() {
+ if test -z "$1"; then
+ echo "$_logp"
+ return
+ fi
+ [ "$1" -le "$_logp" ]
+}
+log_tag() {
+ case $1 in
+ 0) echo "emerg" ;;
+ 1) echo "alert" ;;
+ 2) echo "crit" ;;
+ 3) echo "err" ;;
+ 4) echo "warning" ;;
+ 5) echo "notice" ;;
+ 6) echo "info" ;;
+ 7) echo "debug" ;;
+ *) echo "$1" ;;
+ esac
+}
+log_debug() {
+ log_priority 7 || return 0
+ echoerr "$(log_prefix)" "$(log_tag 7)" "$@"
+}
+log_info() {
+ log_priority 6 || return 0
+ echoerr "$(log_prefix)" "$(log_tag 6)" "$@"
+}
+log_err() {
+ log_priority 3 || return 0
+ echoerr "$(log_prefix)" "$(log_tag 3)" "$@"
+}
+log_crit() {
+ log_priority 2 || return 0
+ echoerr "$(log_prefix)" "$(log_tag 2)" "$@"
+}
+uname_os() {
+ os=$(uname -s | tr '[:upper:]' '[:lower:]')
+ case "$os" in
+ cygwin_nt*) os="windows" ;;
+ mingw*) os="windows" ;;
+ msys_nt*) os="windows" ;;
+ esac
+ echo "$os"
+}
+uname_arch() {
+ arch=$(uname -m)
+ case $arch in
+ x86_64) arch="amd64" ;;
+ x86) arch="386" ;;
+ i686) arch="386" ;;
+ i386) arch="386" ;;
+ aarch64) arch="arm64" ;;
+ armv5*) arch="armv5" ;;
+ armv6*) arch="armv6" ;;
+ armv7*) arch="armv7" ;;
+ esac
+ echo ${arch}
+}
+uname_os_check() {
+ os=$(uname_os)
+ case "$os" in
+ darwin) return 0 ;;
+ dragonfly) return 0 ;;
+ freebsd) return 0 ;;
+ linux) return 0 ;;
+ android) return 0 ;;
+ nacl) return 0 ;;
+ netbsd) return 0 ;;
+ openbsd) return 0 ;;
+ plan9) return 0 ;;
+ solaris) return 0 ;;
+ windows) return 0 ;;
+ esac
+ log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib"
+ return 1
+}
+uname_arch_check() {
+ arch=$(uname_arch)
+ case "$arch" in
+ 386) return 0 ;;
+ amd64) return 0 ;;
+ arm64) return 0 ;;
+ armv5) return 0 ;;
+ armv6) return 0 ;;
+ armv7) return 0 ;;
+ ppc64) return 0 ;;
+ ppc64le) return 0 ;;
+ mips) return 0 ;;
+ mipsle) return 0 ;;
+ mips64) return 0 ;;
+ mips64le) return 0 ;;
+ s390x) return 0 ;;
+ amd64p32) return 0 ;;
+ esac
+ log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib"
+ return 1
+}
+untar() {
+ tarball=$1
+ case "${tarball}" in
+ *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;;
+ *.tar) tar --no-same-owner -xf "${tarball}" ;;
+ *.zip) unzip "${tarball}" ;;
+ *)
+ log_err "untar unknown archive format for ${tarball}"
+ return 1
+ ;;
+ esac
+}
+http_download_curl() {
+ local_file=$1
+ source_url=$2
+ header=$3
+ if [ -z "$header" ]; then
+ code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url")
+ else
+ code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url")
+ fi
+ if [ "$code" != "200" ]; then
+ log_debug "http_download_curl received HTTP status $code"
+ return 1
+ fi
+ return 0
+}
+http_download_wget() {
+ local_file=$1
+ source_url=$2
+ header=$3
+ if [ -z "$header" ]; then
+ wget -q -O "$local_file" "$source_url"
+ else
+ wget -q --header "$header" -O "$local_file" "$source_url"
+ fi
+}
+http_download() {
+ log_debug "http_download $2"
+ if is_command curl; then
+ http_download_curl "$@"
+ return
+ elif is_command wget; then
+ http_download_wget "$@"
+ return
+ fi
+ log_crit "http_download unable to find wget or curl"
+ return 1
+}
+http_copy() {
+ tmp=$(mktemp)
+ http_download "${tmp}" "$1" "$2" || return 1
+ body=$(cat "$tmp")
+ rm -f "${tmp}"
+ echo "$body"
+}
+github_release() {
+ owner_repo=$1
+ version=$2
+ test -z "$version" && version="latest"
+ giturl="https://github.com/${owner_repo}/releases/${version}"
+ json=$(http_copy "$giturl" "Accept:application/json")
+ test -z "$json" && return 1
+ version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')
+ test -z "$version" && return 1
+ echo "$version"
+}
+hash_sha256() {
+ TARGET=${1:-/dev/stdin}
+ if is_command gsha256sum; then
+ hash=$(gsha256sum "$TARGET") || return 1
+ echo "$hash" | cut -d ' ' -f 1
+ elif is_command sha256sum; then
+ hash=$(sha256sum "$TARGET") || return 1
+ echo "$hash" | cut -d ' ' -f 1
+ elif is_command shasum; then
+ hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1
+ echo "$hash" | cut -d ' ' -f 1
+ elif is_command openssl; then
+ hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1
+ echo "$hash" | cut -d ' ' -f a
+ else
+ log_crit "hash_sha256 unable to find command to compute sha-256 hash"
+ return 1
+ fi
+}
+hash_sha256_verify() {
+ TARGET=$1
+ checksums=$2
+ if [ -z "$checksums" ]; then
+ log_err "hash_sha256_verify checksum file not specified in arg2"
+ return 1
+ fi
+ BASENAME=${TARGET##*/}
+ want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)
+ if [ -z "$want" ]; then
+ log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'"
+ return 1
+ fi
+ got=$(hash_sha256 "$TARGET")
+ if [ "$want" != "$got" ]; then
+ log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got"
+ return 1
+ fi
+}
+cat /dev/null < 0 {
+ token = options[0]
+ }
+ if token == "" {
+ token = api.RefreshToken
+ }
+ if len(options) > 1 {
+ id = options[1]
+ }
+ if id == "" {
+ id = api.QingTingId
+ }
+
+ if token == "" {
+ return fmt.Errorf("RefreshToken 为空")
+ }
+ if id == "" {
+ return fmt.Errorf("QingTingId 为空")
+ }
+
+ err := api.post("https://user.qtfm.cn/u2/api/v4/auth", &fetch.Config{
+ Headers: map[string]string{
+ "Content-Type": "application/x-www-form-urlencoded",
+ "User-Agent": UA,
+ },
+ Body: map[string]string{
+ "refresh_token": token,
+ "qingting_id": id,
+ "device_id": deviceId,
+ "grant_type": "refresh_token",
+ },
+ }, &auth)
+
+ if err != nil {
+ return err
+ }
+ api.RefreshToken = auth.RefreshToken
+ api.QingTingId = auth.QingtingID
+ api.AccessToken = auth.AccessToken
+ api.ExpiresIn = auth.ExpiresIn
+ return api.FetchUserInfo()
+}
+
+func (api *QingTingApi) FetchUserInfo() error {
+ if api.RefreshToken == "" {
+ return fmt.Errorf("未授权")
+ }
+
+ var userInfo teacommon.UserInfo
+ url := "https://user.qtfm.cn/u2/api/v5/user/" + api.QingTingId + "?device_id=" + deviceId + "&mode=vital&qingting_id=" + api.QingTingId + "&access_token=" + api.AccessToken
+ err := api.get(url, &fetch.Config{
+ Headers: map[string]string{
+ "Authorization": "Bearer " + api.AccessToken,
+ "User-Agent": UA,
+ },
+ }, &userInfo)
+
+ if err != nil {
+ return err
+ }
+ api.User = userInfo
+ return nil
+}
+
+func (api *QingTingApi) Search(keyword string, searchType string, page int) (teacommon.SearchResult, error) {
+ var result teacommon.SearchResult
+ url := fmt.Sprintf("https://app.qtfm.cn/m-bff/v1/search/result?k=%s&sort_type=%s&page=%s&include=channel_ondemand&pagesize=30&k_src=direct", keyword, searchType, strconv.Itoa(page))
+
+ err := api.get(url, &fetch.Config{}, &result)
+ result.Keyword = keyword
+ result.Type = searchType
+
+ if err != nil {
+ return teacommon.SearchResult{}, err
+ }
+ return result, nil
+}
+
+func (api *QingTingApi) GetChannelInfo(channel teacommon.Channel) teacommon.Channel {
+ url := fmt.Sprintf(`https://app.qtfm.cn/m-bff/v2/channel/%s`, channel.Id)
+ var result teacommon.DetailChannel
+ err := api.get(url, &fetch.Config{}, &result)
+ if err != nil {
+ panic(err)
+ return channel
+ }
+ channel.Count = result.Count
+ return channel
+}
+
+func (api *QingTingApi) FetchPrograms(channel teacommon.Channel, page int) []teacommon.Program {
+ url := fmt.Sprintf(`https://app.qtfm.cn/m-bff/v2/channel/%s/programs?order=asc&pagesize=100&curpage=%d`, channel.Id, page+1)
+ var result struct {
+ Data []teacommon.Program `json:"programs"`
+ }
+ err := api.get(url, &fetch.Config{}, &result)
+ if err != nil {
+ panic(err)
+ return []teacommon.Program{}
+ }
+ return result.Data
+}
+
+func (api *QingTingApi) GetProgramEditions(channel string, program string) []teacommon.Edition {
+ url := fmt.Sprintf(
+ `/m-bff/v1/audiostreams/channel/%s/program/%s?access_token=%s&device_id=%s&qingting_id=%s&type=play`,
+ channel,
+ program,
+ api.AccessToken,
+ deviceId,
+ api.QingTingId,
+ )
+ h := hmac.New(md5.New, []byte(key))
+ h.Write([]byte(url))
+ sign := hex.EncodeToString(h.Sum(nil))
+ url = fmt.Sprintf(`https://app.qtfm.cn%s&sign=%s`, url, sign)
+ var result struct {
+ Editions []teacommon.Edition `json:"editions"`
+ }
+ err := api.get(url, &fetch.Config{}, &result)
+ if err != nil {
+ panic(err)
+ }
+ return result.Editions
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..e823734
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,195 @@
+package config
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+
+ "gopkg.in/yaml.v3"
+)
+
+// AppDir is the name of the directory where the config file is stored.
+const AppDir = "qtg"
+
+// FileName is the name of the config file that gets created.
+const FileName = "config.yml"
+
+// SettingsConfig struct represents the config for the settings.
+type SettingsConfig struct {
+ RefreshToken string `yaml:"refresh_token"`
+ QingTingId string `yaml:"qingting_id"`
+ EnableLogging bool `yaml:"logging"`
+}
+
+type Config struct {
+ Settings SettingsConfig `yaml:"settings"`
+}
+
+// configError represents an error that occurred while parsing the config file.
+type configError struct {
+ configDir string
+ parser Parser
+ err error
+}
+
+// Parser is the parser for the config file.
+type Parser struct{}
+
+// getDefaultConfig returns the default config for the application.
+func (parser Parser) getDefaultConfig() Config {
+ return Config{
+ Settings: SettingsConfig{
+ RefreshToken: "",
+ QingTingId: "",
+ EnableLogging: false,
+ },
+ }
+}
+
+// getDefaultConfigYamlContents returns the default config file contents.
+func (parser Parser) getDefaultConfigYamlContents() string {
+ defaultConfig := parser.getDefaultConfig()
+ config, _ := yaml.Marshal(defaultConfig)
+
+ return string(config)
+}
+
+// Error returns the error message for when a config file is not found.
+func (e configError) Error() string {
+ return fmt.Sprintf(
+ `Couldn't find a config.yml configuration file.
+Create one under: %s
+Example of a config.yml file:
+%s
+For more info, go to https://github.com/mistakenelf/fm
+press q to exit.
+Original error: %v`,
+ path.Join(e.configDir, AppDir, FileName),
+ e.parser.getDefaultConfigYamlContents(),
+ e.err,
+ )
+}
+
+// writeDefaultConfigContents writes the default config file contents to the given file.
+func (parser Parser) writeDefaultConfigContents(newConfigFile *os.File) error {
+ _, err := newConfigFile.WriteString(parser.getDefaultConfigYamlContents())
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// createConfigFileIfMissing creates the config file if it doesn't exist.
+func (parser Parser) createConfigFileIfMissing(configFilePath string) error {
+ if _, err := os.Stat(configFilePath); errors.Is(err, os.ErrNotExist) {
+ newConfigFile, err := os.OpenFile(configFilePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666)
+ if err != nil {
+ return err
+ }
+
+ defer func(newConfigFile *os.File) {
+ _ = newConfigFile.Close()
+ }(newConfigFile)
+ return parser.writeDefaultConfigContents(newConfigFile)
+ }
+
+ return nil
+}
+
+// getConfigFileOrCreateIfMissing returns the config file path or creates the config file if it doesn't exist.
+func (parser Parser) getConfigFileOrCreateIfMissing() (string, error) {
+ var err error
+ configDir := os.Getenv("XDG_CONFIG_HOME")
+
+ if configDir == "" {
+ configDir, err = os.UserHomeDir()
+ if err != nil {
+ return "", configError{parser: parser, configDir: configDir, err: err}
+ }
+ }
+
+ prsConfigDir := filepath.Join(configDir, AppDir)
+ err = os.MkdirAll(prsConfigDir, os.ModePerm)
+ if err != nil {
+ return "", configError{parser: parser, configDir: configDir, err: err}
+ }
+
+ configFilePath := filepath.Join(prsConfigDir, FileName)
+ err = parser.createConfigFileIfMissing(configFilePath)
+ if err != nil {
+ return "", configError{parser: parser, configDir: configDir, err: err}
+ }
+
+ return configFilePath, nil
+}
+
+// parsingError represents an error that occurred while parsing the config file.
+type parsingError struct {
+ err error
+}
+
+// Error represents an error that occurred while parsing the config file.
+func (e parsingError) Error() string {
+ return fmt.Sprintf("failed parsing config.yml: %v", e.err)
+}
+
+// readConfigFile reads the config file and returns the config.
+func (parser Parser) readConfigFile(path string) (Config, error) {
+ config := parser.getDefaultConfig()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return config, configError{parser: parser, configDir: path, err: err}
+ }
+
+ err = yaml.Unmarshal(data, &config)
+ return config, err
+}
+
+// initParser initializes the parser.
+func initParser() Parser {
+ return Parser{}
+}
+
+// ParseConfig parses the config file and returns the config.
+func ParseConfig() (Config, string, error) {
+ var config Config
+ var err error
+
+ parser := initParser()
+
+ configFilePath, err := parser.getConfigFileOrCreateIfMissing()
+ if err != nil {
+ return config, configFilePath, parsingError{err: err}
+ }
+
+ config, err = parser.readConfigFile(configFilePath)
+ if err != nil {
+ return config, configFilePath, parsingError{err: err}
+ }
+
+ return config, configFilePath, nil
+}
+
+func WriteConfig(config *Config) error {
+ parser := initParser()
+ configFilePath, err := parser.getConfigFileOrCreateIfMissing()
+ if err != nil {
+ return err
+ }
+
+ data, err := yaml.Marshal(config)
+ if err != nil {
+ return err
+ }
+
+ err = os.WriteFile(configFilePath, data, 0644)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/internal/download/download.go b/internal/download/download.go
new file mode 100644
index 0000000..7955fa5
--- /dev/null
+++ b/internal/download/download.go
@@ -0,0 +1,267 @@
+package download
+
+import (
+ "fmt"
+ "github.com/cavaliergopher/grab/v3"
+ "github.com/oustn/qtg/internal/api"
+ "github.com/oustn/qtg/internal/meta"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+func leftPad2Len(s string, padStr string, overallLen int) string {
+ var padCountInt = 1 + ((overallLen - len(padStr)) / len(padStr))
+ var retStr = strings.Repeat(padStr, padCountInt) + s
+ return retStr[(len(retStr) - overallLen):]
+}
+
+type downloadPayload struct {
+ channel teacommon.Channel
+ program teacommon.Program
+}
+
+type Downloader struct {
+ api *api.QingTingApi
+ channelConcurrent int
+ programConcurrent int
+ task chan teacommon.Channel // 下载任务 chan
+ taskResult chan Status // 下载任务结果 chan
+ progress lockedChannelProcess // 下载进度
+ wg sync.WaitGroup
+ client *grab.Client
+ Callback func(progress ChannelProgress)
+}
+
+var dl *Downloader
+
+func NewDownloader(
+ qtApi *api.QingTingApi,
+ channelConcurrent int,
+ programConcurrent int,
+) *Downloader {
+ var s sync.Once
+ s.Do(func() {
+ dl = &Downloader{
+ api: qtApi,
+ channelConcurrent: channelConcurrent,
+ programConcurrent: programConcurrent,
+ task: make(chan teacommon.Channel, channelConcurrent),
+ taskResult: make(chan Status),
+ client: grab.NewClient(),
+ }
+ go dl.start()
+ })
+ return dl
+}
+
+func (d *Downloader) start() {
+ for i := 0; i < d.channelConcurrent; i++ {
+ d.wg.Add(1)
+ go func() {
+ defer d.wg.Done()
+ for channel := range d.task {
+ d.download(channel)
+ }
+ }()
+ }
+ for task := range d.taskResult {
+ d.progress.Update(task)
+ if d.Callback != nil {
+ d.Callback(ChannelProgress{
+ Progress: Progress{
+ Pending: d.progress.Pending,
+ Downloading: d.progress.Downloading,
+ Finished: d.progress.Finished,
+ Error: d.progress.Error,
+ },
+ Channels: d.progress.Channels,
+ })
+ }
+ }
+ d.wg.Wait()
+}
+
+func (d *Downloader) DownloadChannel(channel teacommon.Channel) {
+ if d.progress.HasChannel(channel.Id) {
+ return
+ }
+ // println("创建 channel 下载任务" + channel.Name)
+ d.progress.AddChannel(channel)
+ d.task <- channel
+ d.taskResult <- Status{
+ Id: channel.Id,
+ S: Update,
+ }
+}
+
+func (d *Downloader) download(channel teacommon.Channel) {
+ // println("开始下载 channel: " + channel.Name)
+ d.taskResult <- Status{
+ Id: channel.Id,
+ S: Downloading,
+ }
+ s := lockedProgramProcess{}
+ channel = d.api.GetChannelInfo(channel)
+ programCh := make(chan downloadPayload, channel.Count)
+ resultCh := make(chan Status, channel.Count*2)
+ var wg sync.WaitGroup
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+ d.downloadPrograms(programCh, resultCh)
+ }()
+
+ go func() {
+ programs := d.fetchPrograms(channel)
+ for _, program := range programs {
+ s.AddProgram(program)
+
+ programCh <- downloadPayload{
+ channel: channel,
+ program: program,
+ }
+ }
+ close(programCh)
+ wg.Wait()
+ close(resultCh)
+ }()
+
+ for res := range resultCh {
+ s.Update(res)
+ d.progress.UpdatePrograms(channel.Id, ProgramProgress{
+ Progress: s.Progress,
+ Programs: s.Programs,
+ })
+ d.taskResult <- Status{
+ Id: channel.Id,
+ S: Update,
+ }
+ }
+ d.taskResult <- Status{
+ Id: channel.Id,
+ S: Finished,
+ }
+ // println("完成 channel 下载: " + channel.Name)
+}
+
+func (d *Downloader) downloadPrograms(ch <-chan downloadPayload, resultCh chan<- Status) {
+ var wg sync.WaitGroup
+
+ for i := 0; i < d.programConcurrent; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for info := range ch {
+ d.downloadProgram(info, resultCh)
+ }
+ }()
+ }
+
+ wg.Wait()
+}
+
+func (d *Downloader) fetchPrograms(channel teacommon.Channel) []teacommon.Program {
+ // println("开始获取 channel " + channel.Name + " 的 programs")
+
+ var programs []teacommon.Program
+ if channel.Count > 0 {
+ programs = make([]teacommon.Program, channel.Count)
+ }
+
+ pages := int(math.Ceil(float64(channel.Count) / (100.0)))
+ index := 0
+
+ strLen := strings.Count(strconv.Itoa(channel.Count), "") - 1
+
+ for page := range pages {
+ p := d.api.FetchPrograms(channel, page)
+ for _, program := range p {
+ program.Index = index
+ program.NamePrefix = leftPad2Len(strconv.Itoa(index+1), "0", strLen)
+ if channel.Count > 0 {
+ programs[index] = program
+ } else {
+ programs = append(programs, program)
+ }
+ index++
+ }
+ }
+
+ // println("完成获取 channel " + channel.Name + " 的 programs")
+ return programs
+}
+
+func (d *Downloader) downloadProgram(payload downloadPayload, resultCh chan<- Status) {
+ // println("开始下载 Program " + payload.program.Name)
+ resultCh <- Status{
+ Id: payload.program.StringId(),
+ S: Downloading,
+ }
+ handleErr := func(err error) {
+ resultCh <- Status{
+ Id: payload.program.StringId(),
+ S: Errored,
+ }
+ panic(err)
+ }
+ editions := d.api.GetProgramEditions(payload.channel.Id, payload.program.StringId())
+ sort.Sort(teacommon.BySize(editions))
+ e := editions[0]
+ url := e.Urls[0]
+ homeDir, _ := os.UserHomeDir()
+ downloadDir := filepath.Join(homeDir, "Downloads", payload.channel.Name+"-qtg")
+ if os.Mkdir(downloadDir, os.ModePerm) != nil {
+ if os.IsNotExist(os.Mkdir(downloadDir, os.ModePerm)) {
+ panic("无法创建下载目录")
+ }
+ }
+ req, err := grab.NewRequest(downloadDir, url)
+ req.Filename = filepath.Join(downloadDir, fmt.Sprintf("%s.%s.%s", payload.program.NamePrefix, payload.program.Name, e.Format))
+ req.NoResume = true
+ if err != nil {
+ handleErr(err)
+ }
+ resp := d.client.Do(req)
+Loop:
+ for {
+ select {
+ case <-resp.Done:
+ // // println("完成下载 Program " + payload.program.Name)
+ break Loop
+ }
+ }
+ if err := resp.Err(); err != nil {
+ handleErr(err)
+ }
+ err = d.writeTag(resp.Filename, payload)
+ if err != nil {
+ handleErr(err)
+ }
+ resultCh <- Status{
+ Id: payload.program.StringId(),
+ S: Finished,
+ }
+}
+
+func (d *Downloader) Wait() {
+ d.wg.Wait()
+}
+
+func (d *Downloader) writeTag(file string, payload downloadPayload) error {
+ m := meta.Metadata{
+ Title: payload.program.Name,
+ Album: payload.channel.Name,
+ Artist: payload.channel.Podcaster.Name,
+ Description: payload.channel.Description(),
+ Date: payload.program.Date(),
+ Cover: payload.program.Cover,
+ }
+ return meta.WriteMetadata(file, m, true)
+}
diff --git a/internal/download/progress.go b/internal/download/progress.go
new file mode 100644
index 0000000..550e644
--- /dev/null
+++ b/internal/download/progress.go
@@ -0,0 +1,158 @@
+package download
+
+import (
+ mapset "github.com/deckarep/golang-set"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+ "sync"
+)
+
+type status int16
+
+const (
+ Update status = -1
+ Finished status = 0
+ Errored status = 1
+ Downloading status = 2
+)
+
+type Status struct {
+ Id string
+ S status
+}
+
+type Progress struct {
+ Pending mapset.Set
+ Downloading mapset.Set
+ Finished mapset.Set
+ Error mapset.Set
+}
+
+func (p *Progress) Copy() Progress {
+ return Progress{
+ Pending: p.Pending,
+ Downloading: p.Downloading,
+ Finished: p.Finished,
+ Error: p.Error,
+ }
+}
+
+func (p *Progress) Total() int {
+ if p.Pending == nil {
+ return 0
+ }
+ return p.Pending.Cardinality() + p.Downloading.Cardinality() + p.Finished.Cardinality() + p.Error.Cardinality()
+}
+
+type lockedProgress struct {
+ sync.RWMutex
+ Progress
+}
+
+func (l *lockedProgress) Update(s Status) {
+ switch s.S {
+ case Downloading:
+ l.SetDownloading(s.Id)
+ case Finished:
+ l.SetFinished(s.Id)
+ case Errored:
+ l.SetError(s.Id)
+ }
+}
+
+func (l *lockedProgress) SetError(id string) {
+ l.Lock()
+ defer l.Unlock()
+ l.Downloading.Remove(id)
+ l.Error.Add(id)
+}
+
+func (l *lockedProgress) SetFinished(id string) {
+ l.Lock()
+ defer l.Unlock()
+ l.Downloading.Remove(id)
+ l.Finished.Add(id)
+}
+
+func (l *lockedProgress) SetDownloading(id string) {
+ l.Lock()
+ defer l.Unlock()
+ l.Pending.Remove(id)
+ l.Downloading.Add(id)
+}
+
+func (l *lockedProgress) add(id string) {
+ if l.Pending == nil {
+ l.Pending = mapset.NewSet()
+ l.Downloading = mapset.NewSet()
+ l.Finished = mapset.NewSet()
+ l.Error = mapset.NewSet()
+ }
+ l.Pending.Add(id)
+}
+
+type ProgramProgress struct {
+ Progress
+ Programs map[string]teacommon.Program
+}
+
+type Channel struct {
+ teacommon.Channel
+ Programs ProgramProgress
+}
+
+type ChannelProgress struct {
+ Progress
+ Channels map[string]Channel
+}
+
+type lockedProgramProcess struct {
+ lockedProgress
+ Programs map[string]teacommon.Program
+}
+
+func (p *lockedProgramProcess) AddProgram(program teacommon.Program) {
+ p.Lock()
+ defer p.Unlock()
+ if p.Programs == nil {
+ p.Programs = make(map[string]teacommon.Program)
+ }
+ p.Programs[program.StringId()] = program
+ p.add(program.StringId())
+}
+
+type lockedChannelProcess struct {
+ lockedProgress
+ Channels map[string]Channel
+}
+
+func (p *lockedChannelProcess) AddChannel(channel teacommon.Channel) {
+ p.Lock()
+ defer p.Unlock()
+ if p.Channels == nil {
+ p.Channels = make(map[string]Channel)
+ }
+ p.Channels[channel.Id] = Channel{Channel: channel}
+ p.add(channel.Id)
+}
+
+func (p *lockedChannelProcess) HasChannel(id string) bool {
+ _, ok := p.Channels[id]
+ return ok
+}
+
+func (p *lockedChannelProcess) UpdatePrograms(channelId string, s ProgramProgress) {
+ p.Lock()
+ defer p.Unlock()
+ if p.Channels == nil {
+ return
+ }
+ channel, ok := p.Channels[channelId]
+ if !ok {
+ return
+ }
+ channel.Programs = ProgramProgress{
+ Progress: s.Progress,
+ Programs: s.Programs,
+ }
+ p.Channels[channelId] = channel
+}
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
new file mode 100644
index 0000000..cd048bc
--- /dev/null
+++ b/internal/meta/meta.go
@@ -0,0 +1,304 @@
+package meta
+
+import (
+ "fmt"
+ "github.com/abema/go-mp4"
+ "github.com/sunfish-shogi/bufseekio"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+var coverCache = make(map[string][]byte)
+
+type Metadata struct {
+ Title string
+ Album string
+ Artist string
+ Description string
+ Date string
+ Cover string
+}
+
+func (m *Metadata) GetTypeOfTitle() mp4.BoxType {
+ return mp4.BoxType{0xA9, 'n', 'a', 'm'}
+}
+
+func (m *Metadata) GetTypeOfAlbum() mp4.BoxType {
+ return mp4.BoxType{0xA9, 'a', 'l', 'b'}
+}
+
+func (m *Metadata) GetTypeOfArtist() mp4.BoxType {
+ return mp4.BoxType{0xA9, 'A', 'R', 'T'}
+}
+
+func (m *Metadata) GetTypeOfDescription() mp4.BoxType {
+ return mp4.StrToBoxType("desc")
+}
+
+func (m *Metadata) GetTypeOfDate() mp4.BoxType {
+ return mp4.BoxType{0xA9, 'd', 'a', 'y'}
+}
+
+func (m *Metadata) GetTypeOfCover() mp4.BoxType {
+ return mp4.StrToBoxType("covr")
+}
+
+func (m *Metadata) AddMeta(w *mp4.Writer, ctx mp4.Context) error {
+ if err := m.AddTitle(w, ctx); err != nil {
+ return err
+ }
+ if err := m.AddAlbum(w, ctx); err != nil {
+ return err
+ }
+ if err := m.AddArtist(w, ctx); err != nil {
+ return err
+ }
+ if err := m.AddDescription(w, ctx); err != nil {
+ return err
+ }
+ if err := m.AddDate(w, ctx); err != nil {
+ return err
+ }
+ if err := m.AddCover(w, ctx); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (m *Metadata) AddTitle(w *mp4.Writer, ctx mp4.Context) error {
+ return addMeta(w, ctx, m.GetTypeOfTitle(), &mp4.Data{Data: []byte(m.Title), DataType: mp4.DataTypeStringUTF8})
+}
+
+func (m *Metadata) AddAlbum(w *mp4.Writer, ctx mp4.Context) error {
+ return addMeta(w, ctx, m.GetTypeOfAlbum(), &mp4.Data{Data: []byte(m.Album), DataType: mp4.DataTypeStringUTF8})
+}
+
+func (m *Metadata) AddArtist(w *mp4.Writer, ctx mp4.Context) error {
+ return addMeta(w, ctx, m.GetTypeOfArtist(), &mp4.Data{Data: []byte(m.Artist), DataType: mp4.DataTypeStringUTF8})
+}
+
+func (m *Metadata) AddDescription(w *mp4.Writer, ctx mp4.Context) error {
+ return addMeta(w, ctx, m.GetTypeOfDescription(), &mp4.Data{Data: []byte(m.Description), DataType: mp4.DataTypeStringUTF8})
+}
+
+func (m *Metadata) AddDate(w *mp4.Writer, ctx mp4.Context) error {
+ return addMeta(w, ctx, m.GetTypeOfDate(), &mp4.Data{Data: []byte(m.Date), DataType: mp4.DataTypeStringUTF8})
+}
+
+func (m *Metadata) AddCover(w *mp4.Writer, ctx mp4.Context) error {
+ pic, err := getCover(m.Cover)
+ if err != nil {
+ return err
+ }
+ return addMeta(w, ctx, m.GetTypeOfCover(), &mp4.Data{Data: pic, DataType: mp4.DataTypeStringJPEG})
+}
+
+func WriteMetadata(file string, metadata Metadata, rewrite bool) error {
+ input, err := os.Open(file)
+ if err != nil {
+ return err
+ }
+ defer func(input *os.File) {
+ _ = input.Close()
+ }(input)
+
+ out, err := os.Create(file + ".tmp")
+ if err != nil {
+ return err
+ }
+ defer func(out *os.File) {
+ _ = out.Close()
+
+ if rewrite {
+ err = os.Remove(file)
+ if err != nil {
+ panic(err)
+ }
+ err = os.Rename(file+".tmp", file)
+ if err != nil {
+ panic(err)
+ }
+ } else {
+ err = os.Rename(file+".tmp", strings.Replace(file, filepath.Ext(file), "[meta].m4a", 1))
+ if err != nil {
+ panic(err)
+ }
+ }
+ }(out)
+
+ var ilstExists bool
+ var mdatOffsetDiff int64
+ var stcoOffsets []int64
+
+ r := bufseekio.NewReadSeeker(input, 1024*1024, 3)
+ w := mp4.NewWriter(out)
+
+ _, err = mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) {
+ switch h.BoxInfo.Type {
+ case mp4.BoxTypeMoov(),
+ mp4.BoxTypeTrak(),
+ mp4.BoxTypeMdia(),
+ mp4.BoxTypeMinf(),
+ mp4.BoxTypeStbl(),
+ mp4.BoxTypeUdta(),
+ mp4.BoxTypeMeta(),
+ mp4.BoxTypeIlst():
+ _, err := w.StartBox(&h.BoxInfo)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := h.Expand(); err != nil {
+ return nil, err
+ }
+
+ if h.BoxInfo.Type == mp4.BoxTypeMoov() && !ilstExists {
+ path := []mp4.BoxType{mp4.BoxTypeUdta(), mp4.BoxTypeMeta()}
+ for _, boxType := range path {
+ if _, err := w.StartBox(&mp4.BoxInfo{Type: boxType}); err != nil {
+ return nil, err
+ }
+ }
+ ctx := h.BoxInfo.Context
+ ctx.UnderUdta = true
+ if _, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeHdlr()}); err != nil {
+ return nil, err
+ }
+ hdlr := &mp4.Hdlr{
+ HandlerType: [4]byte{'m', 'd', 'i', 'r'},
+ }
+ if _, err := mp4.Marshal(w, hdlr, ctx); err != nil {
+ return nil, err
+ }
+ if _, err := w.EndBox(); err != nil {
+ return nil, err
+ }
+ if _, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeIlst()}); err != nil {
+ return nil, err
+ }
+ ctx.UnderIlst = true
+ if err := metadata.AddMeta(w, ctx); err != nil {
+ return nil, err
+ }
+ if _, err := w.EndBox(); err != nil {
+ return nil, err
+ }
+ for range path {
+ if _, err := w.EndBox(); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if h.BoxInfo.Type == mp4.BoxTypeIlst() {
+ ctx := h.BoxInfo.Context
+ ctx.UnderIlst = true
+ if err := metadata.AddMeta(w, ctx); err != nil {
+ return nil, err
+ }
+ ilstExists = true
+ }
+ if _, err = w.EndBox(); err != nil {
+ return nil, err
+ }
+ default:
+ if h.BoxInfo.Type == mp4.BoxTypeStco() {
+ offset, _ := w.Seek(0, io.SeekCurrent)
+ stcoOffsets = append(stcoOffsets, offset)
+ }
+ if h.BoxInfo.Type == mp4.BoxTypeMdat() {
+ iOffset := int64(h.BoxInfo.Offset)
+ oOffset, _ := w.Seek(0, io.SeekCurrent)
+ mdatOffsetDiff = oOffset - iOffset
+ }
+ if err := w.CopyBox(r, &h.BoxInfo); err != nil {
+ return nil, err
+ }
+ }
+
+ return nil, nil
+ })
+ if err != nil {
+ return err
+ }
+
+ // if mdat box is moved, update stco box
+ if mdatOffsetDiff != 0 {
+ for _, stcoOffset := range stcoOffsets {
+ // seek to stco box header
+ if _, err := out.Seek(stcoOffset, io.SeekStart); err != nil {
+ panic(err)
+ }
+ // read box header
+ bi, err := mp4.ReadBoxInfo(out)
+ if err != nil {
+ panic(err)
+ }
+ // read stco box payload
+ var stco mp4.Stco
+ if _, err := mp4.Unmarshal(out, bi.Size-bi.HeaderSize, &stco, bi.Context); err != nil {
+ panic(err)
+ }
+ // update chunk offsets
+ for i := range stco.ChunkOffset {
+ stco.ChunkOffset[i] += uint32(mdatOffsetDiff)
+ }
+ // seek to stco box payload
+ if _, err := bi.SeekToPayload(out); err != nil {
+ panic(err)
+ }
+ // write stco box payload
+ if _, err := mp4.Marshal(out, &stco, bi.Context); err != nil {
+ panic(err)
+ }
+ }
+ }
+ return nil
+}
+
+func addMeta(w *mp4.Writer, ctx mp4.Context, boxType mp4.BoxType, data *mp4.Data) error {
+ if _, err := w.StartBox(&mp4.BoxInfo{Type: boxType}); err != nil {
+ return err
+ }
+ if _, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeData()}); err != nil {
+ return err
+ }
+ dataCtx := ctx
+ dataCtx.UnderIlstMeta = true
+ if _, err := mp4.Marshal(w, data, dataCtx); err != nil {
+ return err
+ }
+ if _, err := w.EndBox(); err != nil {
+ return err
+ }
+ _, err := w.EndBox()
+ return err
+}
+
+func getCover(url string) ([]byte, error) {
+ if c, ok := coverCache[url]; ok {
+ return c, nil
+ }
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer func(Body io.ReadCloser) {
+ _ = Body.Close()
+ }(resp.Body)
+
+ // 检查响应状态码
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP request failed with status code %d", resp.StatusCode)
+ }
+
+ // 读取整个响应体
+ pic, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ coverCache[url] = pic
+ return pic, nil
+}
diff --git a/internal/ui/auth/auth.go b/internal/ui/auth/auth.go
new file mode 100644
index 0000000..3d0fe4f
--- /dev/null
+++ b/internal/ui/auth/auth.go
@@ -0,0 +1,178 @@
+package teaauth
+
+import (
+ "fmt"
+ "github.com/charmbracelet/bubbles/cursor"
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ teacmd "github.com/oustn/qtg/internal/ui/cmd"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+)
+
+var AuthRoute = teacommon.Route{
+ Title: "🔑 认证",
+
+ Name: teacommon.AuthRoute,
+
+ RenderComponent: func() teacommon.View {
+ a := auth{
+ inputs: make([]textinput.Model, 2),
+ }
+
+ var t textinput.Model
+ for i := range a.inputs {
+ t = textinput.New()
+ t.Cursor.SetMode(cursor.CursorBlink)
+ t.CharLimit = 32
+ t.Prompt = ""
+ switch i {
+ case 0:
+ t.Placeholder = "id xxx"
+ t.Focus()
+ case 1:
+ t.Placeholder = "token xxx"
+ }
+ a.inputs[i] = t
+ }
+ return &a
+ },
+}
+
+type keyMap struct {
+ next key.Binding
+ prev key.Binding
+ confirm key.Binding
+ save key.Binding
+}
+
+var authKeys = keyMap{
+ next: key.NewBinding(key.WithKeys("down", "tab"), key.WithHelp("↓/tab", "下一行")),
+ prev: key.NewBinding(key.WithKeys("up", "shift+tab"), key.WithHelp("↑/shift+tab", "上一行")),
+ confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "认证")),
+ save: key.NewBinding(key.WithKeys("alt+s"), key.WithHelp("alt+s", "保存")),
+}
+
+type auth struct {
+ focusIndex int
+ inputs []textinput.Model
+ authed bool
+ err error
+}
+
+func (a *auth) Init() tea.Cmd {
+ return tea.Batch(textinput.Blink, teacmd.UpdateHelpKeys([]*key.Binding{
+ &authKeys.next,
+ &authKeys.prev,
+ &authKeys.confirm,
+ &authKeys.save,
+ }), teacmd.DisableQQuit())
+}
+
+func (a *auth) Render() string {
+ var message string
+
+ if a.err != nil {
+ message = teacommon.ErrorTextStyle(a.err.Error())
+ } else if a.authed {
+ message = teacommon.SuccessTextStyle("验证成功,已保存到配置文件")
+ } else {
+ message = teacommon.BlurredTextStyle("验证")
+ }
+
+ return fmt.Sprintf(
+ `%s
+%s
+
+%s
+%s
+
+%s
+`,
+ teacommon.FocusedStyle.Width(30).Render("蜻蜓ID"),
+ a.inputs[0].View(),
+ teacommon.FocusedStyle.Width(30).Render("Token"),
+ a.inputs[1].View(),
+ message,
+ ) + "\n"
+}
+
+func (a *auth) Handler(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, authKeys.next):
+ a.nextInput()
+ case key.Matches(msg, authKeys.prev):
+ a.prevInput()
+ case key.Matches(msg, authKeys.confirm):
+ cmd := a.validate()
+ if cmd == nil {
+ return textinput.Blink
+ }
+ return cmd
+ }
+ a.flush()
+ }
+ return a.updateInputs(msg)
+}
+
+func (a *auth) SetSize(width, height int) {
+}
+
+func (a *auth) flush() {
+ for i := range a.inputs {
+ a.inputs[i].Blur()
+ }
+ a.inputs[a.focusIndex].Focus()
+}
+
+func (a *auth) nextInput() {
+ a.focusIndex = (a.focusIndex + 1) % len(a.inputs)
+}
+
+func (a *auth) prevInput() {
+ a.focusIndex--
+ // Wrap around
+ if a.focusIndex < 0 {
+ a.focusIndex = len(a.inputs) - 1
+ }
+}
+
+func (a *auth) updateInputs(msg tea.Msg) tea.Cmd {
+ cmd := make([]tea.Cmd, len(a.inputs))
+
+ // Only text inputs with Focus() set will respond, so it's safe to simply
+ // update all of them here without any further logic.
+ for i := range a.inputs {
+ a.inputs[i], cmd[i] = a.inputs[i].Update(msg)
+ }
+
+ return tea.Batch(cmd...)
+}
+
+func (a *auth) validate() tea.Cmd {
+ id := a.inputs[0].Value()
+ token := a.inputs[1].Value()
+
+ if len(id) != 32 {
+ a.err = fmt.Errorf("请输入32位的蜻蜓ID")
+ a.focusIndex = 0
+ a.flush()
+ return nil
+ }
+ if len(token) != 32 {
+ a.err = fmt.Errorf("请输入32位的Token")
+ a.focusIndex = 1
+ a.flush()
+ return nil
+ }
+ return teacmd.Auth(token, id, func(err error) {
+ if err != nil {
+ a.err = err
+ } else {
+ a.err = nil
+ a.authed = true
+ }
+ })
+}
diff --git a/internal/ui/cmd/cmd.go b/internal/ui/cmd/cmd.go
new file mode 100644
index 0000000..cd941d1
--- /dev/null
+++ b/internal/ui/cmd/cmd.go
@@ -0,0 +1,99 @@
+package teacmd
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+ "time"
+)
+
+func CreateMsg(msg tea.Msg) tea.Cmd {
+ return func() tea.Msg {
+ return msg
+ }
+}
+
+func UpdateStatusBar(title, msg string) tea.Cmd {
+ return CreateMsg(UpdateStatusBarMsg{Title: title, Msg: msg})
+}
+
+func Auth(token, id string, callbacks ...teacommon.Callback) tea.Cmd {
+ callback := func(_ error) {}
+ if len(callbacks) > 0 {
+ callback = callbacks[0]
+ }
+ return CreateMsg(AuthMsg{Id: id, Token: token, Callback: callback})
+}
+
+func DisableQQuit() tea.Cmd {
+ return CreateMsg(UpdateQuitKeyMsg{Q: false, Esc: true})
+}
+func DisableEscQuit() tea.Cmd {
+ return CreateMsg(UpdateQuitKeyMsg{Q: true, Esc: false})
+}
+func DisableBothQuit() tea.Cmd {
+ return CreateMsg(UpdateQuitKeyMsg{})
+}
+
+func EnableQQuit() tea.Cmd {
+ return CreateMsg(UpdateQuitKeyMsg{Q: true})
+}
+
+func EnableEscQuit() tea.Cmd {
+ return CreateMsg(UpdateQuitKeyMsg{Esc: true})
+}
+
+func EnableBothQuit() tea.Cmd {
+ return CreateMsg(UpdateQuitKeyMsg{Q: true, Esc: true})
+}
+
+func Redirect(route string) tea.Cmd {
+ return CreateMsg(RouteMsg{Route: route})
+}
+
+func RedirectHome() tea.Cmd {
+ return Redirect(teacommon.HomeRoute)
+}
+
+func RedirectSearch() tea.Cmd {
+ return Redirect(teacommon.SearchRoute)
+}
+
+func RedirectAuth() tea.Cmd {
+ return Redirect(teacommon.AuthRoute)
+}
+
+func NotificationRedirect() tea.Cmd {
+ return CreateMsg(ViewCreatedMsg{})
+}
+
+func UpdateHelpKeys(keys []*key.Binding) tea.Cmd {
+ return CreateMsg(HelpMsg{keys})
+}
+
+func HandleSearch(keyword string, searchType string, page int) tea.Cmd {
+ return CreateMsg(SearchMsg{
+ Keyword: keyword,
+ Type: searchType,
+ Page: page,
+ })
+}
+
+func HandleSearchResult(result teacommon.SearchResult, err error) tea.Cmd {
+ return CreateMsg(SearchResultMsg{
+ Result: result,
+ Error: err,
+ })
+}
+
+func StartDownload(channel teacommon.Channel) tea.Cmd {
+ return CreateMsg(DownloadMsg{
+ Channel: channel,
+ })
+}
+
+func Tick() tea.Cmd {
+ return tea.Tick(time.Second, func(time.Time) tea.Msg {
+ return TickMsg{}
+ })
+}
diff --git a/internal/ui/cmd/message.go b/internal/ui/cmd/message.go
new file mode 100644
index 0000000..073d081
--- /dev/null
+++ b/internal/ui/cmd/message.go
@@ -0,0 +1,58 @@
+package teacmd
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+)
+
+type (
+ UpdateStatusBarMsg struct {
+ Title string
+ Msg string
+ }
+
+ AuthMsg struct {
+ Id string
+ Token string
+ Callback teacommon.Callback
+ }
+
+ AuthSuccessMsg struct{}
+
+ AuthFailMsg struct {
+ Err error
+ }
+
+ UpdateQuitKeyMsg struct {
+ Q bool
+ Esc bool
+ }
+
+ RouteMsg struct {
+ Route string
+ }
+
+ ViewCreatedMsg struct {
+ }
+
+ HelpMsg struct {
+ Keys []*key.Binding
+ }
+
+ SearchMsg struct {
+ Keyword string
+ Type string
+ Page int
+ }
+
+ SearchResultMsg struct {
+ Error error
+ Result teacommon.SearchResult
+ }
+
+ DownloadMsg struct {
+ Channel teacommon.Channel
+ }
+
+ TickMsg struct{}
+)
diff --git a/internal/ui/common/fm.go b/internal/ui/common/fm.go
new file mode 100644
index 0000000..1192fd9
--- /dev/null
+++ b/internal/ui/common/fm.go
@@ -0,0 +1,90 @@
+package teacommon
+
+import "strconv"
+
+type VipInfo struct {
+ Vip bool `json:"vip"`
+ Expire string `json:"expire_time"`
+}
+
+type PrivateInfo struct {
+ VipInfo VipInfo `json:"vip_info"`
+}
+
+type UserInfo struct {
+ Nickname string `json:"nick_name"`
+ AvatarUrl string `json:"avatar"`
+ PrivateInfo PrivateInfo `json:"private_info"`
+}
+
+type Podcaster struct {
+ Name string `json:"name"`
+}
+type Channel struct {
+ Id string `json:"id"`
+ Name string `json:"title"`
+ Finished bool `json:"is_finished"`
+ Desc string `json:"desc"`
+ Count int `json:"program_count"`
+ Podcaster Podcaster `json:"podcaster"`
+}
+
+func (c Channel) FilterValue() string {
+ return c.Name
+}
+
+func (c Channel) Title() string {
+ return c.Name
+}
+
+func (c Channel) Description() string {
+ return c.Desc
+}
+
+func (c Channel) Author() string {
+ return c.Podcaster.Name
+}
+
+type SearchResult struct {
+ Keyword string
+ Type string
+ Page int `json:"page"`
+ PageSize int `json:"pagesize"`
+ Total int `json:"total"`
+ Data []Channel `json:"data"`
+}
+
+type DetailChannel struct {
+ Id int `json:"id"`
+ Count int `json:"program_count"`
+}
+
+type Program struct {
+ Id int `json:"id"`
+ Name string `json:"title"`
+ Cover string `json:"cover"`
+ UpdateTime string `json:"update_time"`
+ Index int
+ NamePrefix string
+}
+
+func (p *Program) Date() string {
+ return p.UpdateTime[:4]
+}
+
+func (p *Program) StringId() string {
+ return strconv.Itoa(p.Id)
+}
+
+type Edition struct {
+ Format string `json:"format"`
+ Bitrate int `json:"bitrate"`
+ Size int `json:"size"`
+ Urls []string `json:"urls"`
+}
+
+type BySize []Edition
+
+func (a BySize) Len() int { return len(a) }
+func (a BySize) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
+func (a BySize) Less(i, j int) bool { return a[i].Size > a[j].Size }
diff --git a/internal/ui/common/key.go b/internal/ui/common/key.go
new file mode 100644
index 0000000..b6a339d
--- /dev/null
+++ b/internal/ui/common/key.go
@@ -0,0 +1,26 @@
+package teacommon
+
+import "github.com/charmbracelet/bubbles/key"
+
+var Keys = []key.Binding{
+ key.NewBinding(
+ key.WithKeys("?"),
+ key.WithHelp("?", "帮助"),
+ ),
+ key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "退出"),
+ ),
+ key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("up", "上一个"),
+ ),
+ key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("down", "下一个"),
+ ),
+ key.NewBinding(
+ key.WithKeys("left"),
+ key.WithHelp("left", "左边"),
+ ),
+}
diff --git a/internal/ui/common/theme.go b/internal/ui/common/theme.go
new file mode 100644
index 0000000..181ff2c
--- /dev/null
+++ b/internal/ui/common/theme.go
@@ -0,0 +1,77 @@
+package teacommon
+
+import (
+ "github.com/charmbracelet/lipgloss"
+ "github.com/lucasb-eyer/go-colorful"
+)
+
+func makeStyle(color lipgloss.TerminalColor) lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(color)
+
+}
+
+func makeTextStyle(color lipgloss.TerminalColor) func(...string) string {
+ return makeStyle(color).Render
+}
+
+var (
+ White = lipgloss.Color("#ffffff")
+ TitleColor = lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#24292f"}
+ SecondaryTextColor = lipgloss.AdaptiveColor{Dark: "#4d4d4d", Light: "#57606a"}
+ ErrorColor = lipgloss.AdaptiveColor{Dark: "#F78166", Light: "#cf222e"}
+ WarnColor = lipgloss.AdaptiveColor{Dark: "#E3B341", Light: "#9a6700"}
+ SuccessColor = lipgloss.AdaptiveColor{Dark: "#56D364", Light: "#1a7f37"}
+ PrimaryColor = lipgloss.AdaptiveColor{Dark: "#6CA4F8", Light: "#0969da"}
+ AccentColor = lipgloss.AdaptiveColor{Dark: "#DB61A2", Light: "#8250df"}
+
+ TitleTextStyle = makeTextStyle(TitleColor) // title text style
+ BlurredStyle = makeStyle(SecondaryTextColor) // blurred style
+ BlurredTextStyle = makeTextStyle(SecondaryTextColor) // help text style
+ FocusedStyle = makeStyle(AccentColor) // focused style
+ FocusedTextStyle = makeTextStyle(AccentColor) // focused text style
+ SuccessStyle = makeStyle(SuccessColor) // error style
+ SuccessTextStyle = makeTextStyle(SuccessColor) // success text style
+ ErrorTextStyle = makeTextStyle(ErrorColor) // error text style
+ NoStyle = lipgloss.NewStyle() // no style
+ CursorStyle = FocusedStyle.Copy() // cursor style
+
+ InfoStyle = makeStyle(PrimaryColor) // primary style
+ InfoTextStyle = makeStyle(PrimaryColor).Italic(true).Render // primary style
+
+ StatusBarSelectedFileForegroundColor = lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#ffffff"}
+ StatusBarSelectedFileBackgroundColor = lipgloss.AdaptiveColor{Dark: "#F25D94", Light: "#F25D94"}
+ StatusBarBarForegroundColor = lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#ffffff"}
+ StatusBarBarBackgroundColor = lipgloss.AdaptiveColor{Dark: "#3c3836", Light: "#3c3836"}
+ StatusBarTotalFilesForegroundColor = lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#ffffff"}
+ StatusBarTotalFilesBackgroundColor = lipgloss.AdaptiveColor{Dark: "#A550DF", Light: "#A550DF"}
+ StatusBarLogoForegroundColor = lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#ffffff"}
+ StatusBarLogoBackgroundColor = lipgloss.AdaptiveColor{Dark: "#6124DF", Light: "#6124DF"}
+)
+
+func ColorGrid(xSteps, ySteps int) [][]string {
+ x0y0, _ := colorful.Hex("#F25D94")
+ x1y0, _ := colorful.Hex("#EDFF82")
+ x0y1, _ := colorful.Hex("#643AFF")
+ x1y1, _ := colorful.Hex("#14F9D5")
+
+ x0 := make([]colorful.Color, ySteps)
+ for i := range x0 {
+ x0[i] = x0y0.BlendLuv(x0y1, float64(i)/float64(ySteps))
+ }
+
+ x1 := make([]colorful.Color, ySteps)
+ for i := range x1 {
+ x1[i] = x1y0.BlendLuv(x1y1, float64(i)/float64(ySteps))
+ }
+
+ grid := make([][]string, ySteps)
+ for x := 0; x < ySteps; x++ {
+ y0 := x0[x]
+ grid[x] = make([]string, xSteps)
+ for y := 0; y < xSteps; y++ {
+ grid[x][y] = y0.BlendLuv(x1[x], float64(y)/float64(xSteps)).Hex()
+ }
+ }
+
+ return grid
+}
diff --git a/internal/ui/common/view.go b/internal/ui/common/view.go
new file mode 100644
index 0000000..bed2b9d
--- /dev/null
+++ b/internal/ui/common/view.go
@@ -0,0 +1,32 @@
+package teacommon
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type (
+ View interface {
+ Component
+ Init() tea.Cmd
+ }
+
+ Component interface {
+ Render() string
+ Handler(msg tea.Msg) tea.Cmd
+ SetSize(width, height int)
+ }
+
+ RenderComponent func() View
+)
+
+type Route struct {
+ Title string
+ Name string
+ RenderComponent RenderComponent
+}
+
+const (
+ HomeRoute = "home"
+ SearchRoute = "search"
+ AuthRoute = "auth"
+)
+
+type Callback func(err error)
diff --git a/internal/ui/components/banner.go b/internal/ui/components/banner.go
new file mode 100644
index 0000000..cb8293a
--- /dev/null
+++ b/internal/ui/components/banner.go
@@ -0,0 +1,79 @@
+package teacomponents
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/lucasb-eyer/go-colorful"
+ "strings"
+)
+
+// from https://www.patorjk.com/software/taag/#p=display&f=BlurVision%20ASCII&t=QTG
+var (
+ banner = `┏┓┏┳┓┏┓
+┃┃ ┃ ┃┓
+┗┻ ┻ ┗┛
+一个有趣的蜻蜓FM下载工具`
+)
+
+func NewBanner() *Banner {
+ b := Banner{}
+ return &b
+}
+
+type Banner struct {
+ width int
+}
+
+func (b *Banner) Render() string {
+ str := strings.Split(banner, "\n")
+ colors := colorGrid(len([]rune(str[3])), len(str))
+ sb := strings.Builder{}
+ for i, x := range colors {
+ for j, y := range x {
+ charset := []rune(str[i])
+ if j < len(charset) {
+ s := lipgloss.NewStyle().SetString(string([]rune(str[i])[j])).Foreground(lipgloss.Color(y))
+ sb.WriteString(s.String())
+ }
+ }
+ sb.WriteRune('\n')
+ }
+
+ return sb.String()
+}
+
+func (b *Banner) Handler(msg tea.Msg) tea.Cmd {
+ return nil
+}
+
+func (b *Banner) SetSize(width, _ int) {
+ b.width = width
+}
+
+func colorGrid(xSteps, ySteps int) [][]string {
+ x0y0, _ := colorful.Hex("#F25D94")
+ x1y0, _ := colorful.Hex("#EDFF82")
+ x0y1, _ := colorful.Hex("#643AFF")
+ x1y1, _ := colorful.Hex("#14F9D5")
+
+ x0 := make([]colorful.Color, ySteps)
+ for i := range x0 {
+ x0[i] = x0y0.BlendLuv(x0y1, float64(i)/float64(ySteps))
+ }
+
+ x1 := make([]colorful.Color, ySteps)
+ for i := range x1 {
+ x1[i] = x1y0.BlendLuv(x1y1, float64(i)/float64(ySteps))
+ }
+
+ grid := make([][]string, ySteps)
+ for x := 0; x < ySteps; x++ {
+ y0 := x0[x]
+ grid[x] = make([]string, xSteps)
+ for y := 0; y < xSteps; y++ {
+ grid[x][y] = y0.BlendLuv(x1[x], float64(y)/float64(xSteps)).Hex()
+ }
+ }
+
+ return grid
+}
diff --git a/internal/ui/components/helper.go b/internal/ui/components/helper.go
new file mode 100644
index 0000000..178162c
--- /dev/null
+++ b/internal/ui/components/helper.go
@@ -0,0 +1,284 @@
+package teacomponents
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "math"
+ "strings"
+)
+
+var (
+ keyWidth = 14
+)
+
+func NewHelper() Helper {
+ keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
+ Light: "#909090",
+ Dark: "#626262",
+ })
+
+ descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
+ Light: "#B2B2B2",
+ Dark: "#4A4A4A",
+ })
+
+ sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
+ Light: "#DDDADA",
+ Dark: "#3C3C3C",
+ })
+
+ return Helper{
+ Separator: " ",
+ ShortSeparator: " • ",
+ Ellipsis: "…",
+ Title: "帮助",
+ Styles: Styles{
+ ShortKey: keyStyle,
+ ShortDesc: descStyle,
+ ShortSeparator: sepStyle,
+ Ellipsis: sepStyle.Copy(),
+ Key: keyStyle.Copy().
+ Bold(true).
+ MarginRight(2).
+ Foreground(lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#000000"}),
+ Desc: descStyle.Copy().
+ Foreground(lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#000000"}).
+ Copy(),
+ Separator: sepStyle.Copy(),
+ },
+ HelpKey: key.NewBinding(
+ key.WithKeys("?"),
+ key.WithHelp("?", "帮助"),
+ ),
+ EscQuitKey: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "返回主页"),
+ ),
+ QQuitKey: key.NewBinding(
+ key.WithKeys("q"),
+ key.WithHelp("q", "返回主页"),
+ ),
+ ForceQuitKey: key.NewBinding(
+ key.WithKeys("ctrl+c"),
+ key.WithHelp("ctrl+c", "强制退出"),
+ ),
+ }
+}
+
+type Styles struct {
+ Ellipsis lipgloss.Style
+ TitleStyle lipgloss.Style
+ ShortKey lipgloss.Style
+ ShortDesc lipgloss.Style
+ ShortSeparator lipgloss.Style
+ Key lipgloss.Style
+ Desc lipgloss.Style
+ Separator lipgloss.Style
+}
+
+type Helper struct {
+ Width int
+ Show bool
+ Separator string
+ ShortSeparator string
+ Ellipsis string
+ Styles Styles
+ keys []*key.Binding
+ HelpKey key.Binding
+ QQuitKey key.Binding
+ EscQuitKey key.Binding
+ ForceQuitKey key.Binding
+ Title string
+}
+
+func (h *Helper) Handler(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ h.SetSize(msg.Width, msg.Height)
+ }
+ return nil
+}
+
+func (h *Helper) Render() string {
+ if h.Show {
+ return lipgloss.NewStyle().
+ Render(lipgloss.JoinVertical(
+ lipgloss.Top,
+ h.renderTitle(),
+ h.renderFull(),
+ ))
+ }
+ return h.renderShort()
+}
+
+func (h *Helper) renderTitle() string {
+ return h.Styles.TitleStyle.Render(h.Title)
+}
+
+func (h *Helper) renderFull() string {
+ if len(h.keys) == 0 {
+ return ""
+ }
+ var (
+ out []string
+ sep = h.Styles.Separator.Render(h.Separator)
+ sepWidth = lipgloss.Width(sep)
+ maxWidth = 0
+ )
+
+ maxKeyWidth := h.resolveMaxKey()
+
+ for _, kb := range h.keys {
+ if !kb.Enabled() {
+ continue
+ }
+ helpStr, helpWidth := h.renderKey(kb.Help().Key, kb.Help().Desc, maxKeyWidth)
+ if helpWidth > maxWidth {
+ maxWidth = helpWidth
+ }
+ out = append(out, helpStr)
+ }
+
+ column := int(math.Floor(float64(h.Width+sepWidth) / float64(maxWidth+sepWidth)))
+
+ if column < 1 {
+ column = 1
+ } else if column > 3 {
+ column = 3
+ }
+
+ row := int(math.Floor(float64(len(out)) / float64(column)))
+ rest := len(out) % column
+ columns := make([]string, column)
+
+ columnStyle := lipgloss.NewStyle().Width(maxWidth + sepWidth)
+
+ j := 0
+ current := 0
+
+ for _, str := range out {
+ columns[j] = lipgloss.JoinVertical(lipgloss.Left, columns[j], columnStyle.Render(str))
+ current++
+
+ if j < rest {
+ if current == row+1 {
+ j++
+ current = 0
+ }
+ } else {
+ if current == row {
+ j++
+ current = 0
+ }
+ }
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Top, lipgloss.JoinHorizontal(lipgloss.Left, columns...))
+}
+
+func (h *Helper) resolveMaxKey() int {
+ maxLen := 0
+ for _, kb := range h.keys {
+ keyText := h.Styles.Key.Copy().Render(kb.Help().Key)
+ w := lipgloss.Width(keyText)
+ if w > keyWidth {
+ return keyWidth
+ }
+ if w > maxLen {
+ maxLen = w
+ }
+ }
+ return maxLen
+}
+
+func (h *Helper) renderKey(key, desc string, maxKeyWidth int) (str string, width int) {
+ keyText := h.Styles.Key.Copy().Width(maxKeyWidth).
+ Render(key)
+
+ descriptionText := h.Styles.Desc.
+ Render(desc)
+
+ str = lipgloss.JoinHorizontal(lipgloss.Top, keyText, descriptionText)
+ width = lipgloss.Width(str)
+ return
+}
+
+func (h *Helper) renderShort() string {
+ bindings := []key.Binding{h.HelpKey, h.EscQuitKey, h.QQuitKey, h.ForceQuitKey}
+
+ if len(bindings) == 0 {
+ return ""
+ }
+
+ var b strings.Builder
+ var totalWidth int
+ var separator = h.Styles.ShortSeparator.Inline(true).Render(h.ShortSeparator)
+
+ for i, kb := range bindings {
+ if !kb.Enabled() {
+ continue
+ }
+
+ var sep string
+ if totalWidth > 0 && i < len(bindings) {
+ sep = separator
+ }
+
+ str := sep +
+ h.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " +
+ h.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc)
+
+ w := lipgloss.Width(str)
+
+ // If adding this help item would go over the available width, stop
+ // drawing.
+ if h.Width > 0 && totalWidth+w > h.Width {
+ // Although if there's room for an ellipsis, print that.
+ tail := " " + h.Styles.Ellipsis.Inline(true).Render(h.Ellipsis)
+ tailWidth := lipgloss.Width(tail)
+
+ if totalWidth+tailWidth < h.Width {
+ b.WriteString(tail)
+ }
+
+ break
+ }
+
+ totalWidth += w
+ b.WriteString(str)
+ }
+
+ return b.String()
+}
+
+func (h *Helper) SetSize(width, _ int) {
+ h.Width = width
+}
+
+func (h *Helper) SetKeys(keys []*key.Binding) {
+ h.keys = keys
+}
+
+func (h *Helper) ToggleShowAll() tea.Cmd {
+ h.Show = !h.Show
+ return nil
+}
+
+func (h *Helper) SetEscQuitEnable(enable bool) {
+ h.EscQuitKey.SetEnabled(enable)
+}
+
+func (h *Helper) SetQQuitEnable(enable bool) {
+ h.QQuitKey.SetEnabled(enable)
+}
+
+func (h *Helper) ResetQuit() {
+ h.QQuitKey.SetEnabled(true)
+ h.EscQuitKey.SetEnabled(true)
+}
+
+func (h *Helper) ClearKeys() {
+ h.ResetQuit()
+ h.keys = []*key.Binding{}
+}
diff --git a/internal/ui/components/list.go b/internal/ui/components/list.go
new file mode 100644
index 0000000..78e8948
--- /dev/null
+++ b/internal/ui/components/list.go
@@ -0,0 +1,150 @@
+package teacomponents
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+ "strings"
+)
+
+type ListItem interface {
+ list.Item
+ Title() string
+ Description() string
+}
+
+var keyMap = list.KeyMap{
+ // Browsing.
+ CursorUp: key.NewBinding(
+ key.WithKeys("up", "k"),
+ key.WithHelp("↑/k", "上一项"),
+ ),
+ CursorDown: key.NewBinding(
+ key.WithKeys("down", "j"),
+ key.WithHelp("↓/j", "下一项"),
+ ),
+ PrevPage: key.NewBinding(
+ key.WithKeys("left", "h", "pgup", "b", "u"),
+ key.WithHelp("←/h/pgup", "上一页"),
+ ),
+ NextPage: key.NewBinding(
+ key.WithKeys("right", "l", "pgdown", "f", "d"),
+ key.WithHelp("→/l/pgdn", "下一页"),
+ ),
+ GoToStart: key.NewBinding(
+ key.WithKeys("home", "g"),
+ key.WithHelp("g/home", "跳转到开头"),
+ ),
+ GoToEnd: key.NewBinding(
+ key.WithKeys("end", "G"),
+ key.WithHelp("G/end", "跳转到结尾"),
+ ),
+ Filter: key.NewBinding(
+ key.WithKeys("/"),
+ key.WithHelp("/", "输入关键字"),
+ ),
+ ClearFilter: key.NewBinding(
+ key.WithKeys("/"),
+ key.WithHelp("/", "清空关键字"),
+ ),
+
+ // Filtering.
+ CancelWhileFiltering: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "退出搜索"),
+ ),
+
+ AcceptWhileFiltering: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "搜索"),
+ ),
+}
+
+func NewList(items []ListItem) *List {
+ var listItems []list.Item
+ //for _, item := range items {
+ // listItems = append(listItems, item)
+ //}
+ l := List{
+ list: list.New(listItems, list.NewDefaultDelegate(), 0, 0),
+ }
+ l.list.Styles.Title = lipgloss.NewStyle()
+ l.list.SetShowHelp(false)
+ l.list.DisableQuitKeybindings()
+ l.list.SetFilteringEnabled(true)
+ l.list.SetShowStatusBar(false)
+ l.list.KeyMap = keyMap
+ l.ListKeyMap = keyMap
+ return &l
+}
+
+type List struct {
+ list list.Model
+ Items *[]ListItem
+ Title teacommon.Component
+ EmptyTip string
+ ListKeyMap list.KeyMap
+}
+
+func (l *List) Render() string {
+ if l.Title != nil {
+ l.list.Title = l.Title.Render()
+ }
+ return strings.Replace(l.list.View(), "No items.", l.EmptyTip, 1)
+}
+
+func (l *List) Handler(msg tea.Msg) tea.Cmd {
+ var commands []tea.Cmd
+ switch msg.(type) {
+ case tea.KeyMsg:
+ // Don't match any of the keys below if we're actively filtering.
+ if l.list.FilterState() == list.Filtering {
+ break
+ }
+ }
+
+ newListModel, cmd := l.list.Update(msg)
+ l.list = newListModel
+ commands = append(commands, cmd)
+ return tea.Batch(commands...)
+}
+
+func (l *List) SetSize(width, height int) {
+ l.list.SetSize(width, height)
+}
+
+func (l *List) BindFilterKeys() {
+ l.ListKeyMap.Filter.SetEnabled(false)
+ l.ListKeyMap.ClearFilter.SetEnabled(true)
+ l.ListKeyMap.CancelWhileFiltering.SetEnabled(true)
+}
+
+func (l *List) UnbindFilterKeys() {
+ l.ListKeyMap.Filter.SetEnabled(true)
+ l.ListKeyMap.ClearFilter.SetEnabled(false)
+ l.ListKeyMap.CancelWhileFiltering.SetEnabled(false)
+}
+
+func (l *List) Loading(b bool) {
+ if b {
+ l.list.StartSpinner()
+ } else {
+ l.list.StopSpinner()
+ }
+}
+
+func (l *List) SetEntity(items []ListItem) {
+ var listItems []list.Item
+ for _, item := range items {
+ listItems = append(listItems, item)
+ }
+ l.list.SetItems(listItems)
+}
+
+func (l *List) GetSelect() ListItem {
+ item := l.list.SelectedItem()
+
+ return item.(ListItem)
+}
diff --git a/internal/ui/components/status-bar.go b/internal/ui/components/status-bar.go
new file mode 100644
index 0000000..36b9e3d
--- /dev/null
+++ b/internal/ui/components/status-bar.go
@@ -0,0 +1,163 @@
+package teacomponents
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/muesli/reflow/truncate"
+)
+
+// Height represents the height of the statusbar.
+const Height = 1
+
+type ColorConfig struct {
+ Foreground lipgloss.AdaptiveColor
+ Background lipgloss.AdaptiveColor
+}
+
+type Column struct {
+ Content string
+ Foreground lipgloss.AdaptiveColor
+ Background lipgloss.AdaptiveColor
+ Width int
+ ContentWidth int
+ Flex bool
+}
+
+type renderColumn struct {
+ content string
+ style lipgloss.Style
+ width int
+ flex bool
+ index int
+}
+
+type StatusBar struct {
+ Width int
+ Height int
+ Columns []Column
+}
+
+func NewStatusBar(column int) StatusBar {
+ return StatusBar{
+ Height: Height,
+ Columns: make([]Column, column),
+ }
+}
+
+func (m *StatusBar) SetSize(width, height int) {
+ m.Width = width
+}
+
+func (m *StatusBar) Handler(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.SetSize(msg.Width, msg.Height)
+ }
+
+ return nil
+}
+
+func (m *StatusBar) SetColumns(columns []Column) {
+ m.Columns = columns[0:len(m.Columns)]
+}
+
+func (m *StatusBar) UpdateColumn(index int, content string) {
+ if index > len(m.Columns) {
+ return
+ }
+ m.Columns[index].Content = content
+}
+
+func (m *StatusBar) UpdateColumnContent(index int, content string) {
+ if index > len(m.Columns) {
+ return
+ }
+ m.Columns[index].Content = content
+}
+
+func (m *StatusBar) UpdateColumnForeground(index int, color lipgloss.AdaptiveColor) {
+ if index > len(m.Columns) {
+ return
+ }
+ m.Columns[index].Foreground = color
+}
+
+func (m *StatusBar) UpdateColumnBackground(index int, color lipgloss.AdaptiveColor) {
+ if index > len(m.Columns) {
+ return
+ }
+ m.Columns[index].Background = color
+}
+
+func (m *StatusBar) UpdateColumnWidth(index int, width int) {
+ if index > len(m.Columns) {
+ return
+ }
+ m.Columns[index].ContentWidth = width
+}
+
+func (m *StatusBar) Render() string {
+ width := lipgloss.Width
+
+ var columns []renderColumn
+ var contents []string
+
+ for i, column := range m.Columns {
+ style := lipgloss.NewStyle().Padding(0, 1).Height(m.Height)
+ if column.Foreground != (lipgloss.AdaptiveColor{}) {
+ style = style.Foreground(column.Foreground)
+ }
+ if column.Background != (lipgloss.AdaptiveColor{}) {
+ style = style.Background(column.Background)
+ }
+ if column.Width > 0 {
+ style.Width(column.Width)
+ }
+ if column.Flex {
+ columns = append(columns, renderColumn{
+ content: column.Content,
+ style: style,
+ index: i,
+ })
+ contents = append(contents, column.Content)
+ continue
+ }
+ if column.ContentWidth > 0 {
+ content := style.Render(truncate.StringWithTail(column.Content, uint(column.ContentWidth), "..."))
+ columns = append(columns, renderColumn{
+ content: content,
+ width: width(content),
+ index: i,
+ })
+ contents = append(contents, content)
+ continue
+ }
+ content := style.Render(column.Content)
+ columns = append(columns, renderColumn{
+ content: content,
+ width: width(content),
+ index: i,
+ })
+ contents = append(contents, content)
+ }
+
+ availableWidth := m.Width
+ flexColumns := make([]int, 0)
+ for i, column := range columns {
+ if column.width == 0 {
+ flexColumns = append(flexColumns, i)
+ } else {
+ availableWidth -= column.width
+ }
+ }
+
+ if len(flexColumns) > 0 {
+ flexWidth := availableWidth / len(flexColumns)
+ for _, i := range flexColumns {
+ columns[i].style.Width(flexWidth)
+ contents[i] = columns[i].style.Render(truncate.StringWithTail(contents[i], uint(flexWidth), "..."))
+ }
+ }
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, contents...)
+}
diff --git a/internal/ui/home/home.go b/internal/ui/home/home.go
new file mode 100644
index 0000000..e21945e
--- /dev/null
+++ b/internal/ui/home/home.go
@@ -0,0 +1,130 @@
+package teahome
+
+import (
+ "fmt"
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ teaauth "github.com/oustn/qtg/internal/ui/auth"
+ teacmd "github.com/oustn/qtg/internal/ui/cmd"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+ teasearch "github.com/oustn/qtg/internal/ui/search"
+ "strings"
+)
+
+func Checkbox(label string, checked bool) string {
+ if checked {
+ return teacommon.FocusedTextStyle("[x] " + label)
+ }
+ return fmt.Sprintf("[ ] %s", label)
+}
+
+type homeKeys struct {
+ Up key.Binding
+ Down key.Binding
+ Enter key.Binding
+}
+
+var HomeRoute = teacommon.Route{
+ Title: "🏠 主页",
+ RenderComponent: func() teacommon.View {
+ return &home{
+ choice: routes[0].Name,
+ keys: homeKeys{
+ Up: key.NewBinding(
+ key.WithKeys("up", "k"),
+ key.WithHelp("↑/k", "选择上一个"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down", "j"),
+ key.WithHelp("↓/j", "选择下一个"),
+ ),
+ Enter: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("↩︎ ", "进入"),
+ ),
+ },
+ }
+ },
+}
+
+var routes = []teacommon.Route{
+ teaauth.AuthRoute,
+ teasearch.SearchRoute,
+}
+
+type home struct {
+ choice string
+ ticks int
+ keys homeKeys
+}
+
+func (h *home) Init() tea.Cmd {
+ return teacmd.UpdateHelpKeys([]*key.Binding{
+ &h.keys.Up,
+ &h.keys.Down,
+ &h.keys.Enter,
+ })
+}
+
+func (h *home) Render() string {
+ tpl := "%s\n\n"
+ boxes := make([]interface{}, len(routes))
+ formats := make([]string, len(routes))
+
+ for i, route := range routes {
+ formats[i] = "%s"
+ boxes[i] = Checkbox(route.Title, h.choice == route.Name)
+ }
+ choices := fmt.Sprintf(
+ strings.Join(formats, "\n"),
+ boxes...,
+ )
+ return fmt.Sprintf(tpl, choices)
+}
+
+func (h *home) Handler(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case teacmd.ViewCreatedMsg:
+ return teacmd.UpdateStatusBar("主页", "")
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, h.keys.Up):
+ h.selectUp()
+ case key.Matches(msg, h.keys.Down):
+ h.selectDown()
+ case key.Matches(msg, h.keys.Enter):
+ return teacmd.Redirect(h.choice)
+ }
+ }
+ return nil
+}
+
+func (h *home) selectDown() {
+ choice := h.indexOfChoice()
+ choice++
+ if choice > len(routes)-1 {
+ choice = len(routes) - 1
+ }
+ h.choice = routes[choice].Name
+}
+
+func (h *home) selectUp() {
+ choice := h.indexOfChoice()
+ choice--
+ if choice < 0 {
+ choice = 0
+ }
+ h.choice = routes[choice].Name
+}
+
+func (h *home) indexOfChoice() int {
+ for i, route := range routes {
+ if route.Name == h.choice {
+ return i
+ }
+ }
+ return -1
+}
+
+func (h *home) SetSize(width, height int) {
+}
diff --git a/internal/ui/renderer.go b/internal/ui/renderer.go
new file mode 100644
index 0000000..843319a
--- /dev/null
+++ b/internal/ui/renderer.go
@@ -0,0 +1,329 @@
+package teaui
+
+import (
+ "fmt"
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/oustn/qtg/internal/api"
+ "github.com/oustn/qtg/internal/config"
+ "github.com/oustn/qtg/internal/download"
+ teaauth "github.com/oustn/qtg/internal/ui/auth"
+ teacmd "github.com/oustn/qtg/internal/ui/cmd"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+ teacomponents "github.com/oustn/qtg/internal/ui/components"
+ teahome "github.com/oustn/qtg/internal/ui/home"
+ teasearch "github.com/oustn/qtg/internal/ui/search"
+ "strings"
+)
+
+var (
+ appStyle = lipgloss.NewStyle().Padding(1, 2)
+ logoText = fmt.Sprintf("%s %s", "🐲", "QTG")
+ maxHeight = 32
+)
+
+func newStatusBar() teacomponents.StatusBar {
+ bar := teacomponents.NewStatusBar(4)
+ bar.SetColumns([]teacomponents.Column{
+ {
+ Content: "QTG",
+ Foreground: teacommon.StatusBarLogoForegroundColor,
+ Background: teacommon.StatusBarLogoBackgroundColor,
+ ContentWidth: 30,
+ },
+ {
+ Content: "一个有趣的蜻蜓FM下载工具",
+ Foreground: teacommon.StatusBarBarForegroundColor,
+ Background: teacommon.StatusBarBarBackgroundColor,
+ Flex: true,
+ },
+ {
+ Foreground: teacommon.StatusBarTotalFilesForegroundColor,
+ Background: teacommon.StatusBarTotalFilesBackgroundColor,
+ },
+ {
+ Content: logoText,
+ Foreground: teacommon.StatusBarLogoForegroundColor,
+ Background: teacommon.StatusBarLogoBackgroundColor,
+ },
+ })
+ return bar
+}
+
+func NewRenderer(cfg *config.Config, cfgPath string) tea.Model {
+ qtApi := api.InitQingTingApi(cfg.Settings.RefreshToken, cfg.Settings.QingTingId)
+ _ = qtApi.Auth()
+
+ helper := teacomponents.NewHelper()
+ helper.Styles.TitleStyle = lipgloss.NewStyle().
+ Foreground(teacommon.AccentColor).
+ Bold(true).
+ Italic(true)
+
+ downloader := download.NewDownloader(qtApi, 1, 5)
+
+ r := renderer{
+ cfg: cfg,
+ cfgPath: cfgPath,
+ api: qtApi,
+ statusbar: newStatusBar(),
+ helper: helper,
+ banner: teacomponents.NewBanner(),
+ downloader: downloader,
+ }
+
+ downloader.Callback = r.updateProgress
+
+ return r
+}
+
+type (
+ renderer struct {
+ route *teacommon.Route
+ cfg *config.Config
+ cfgPath string
+ api *api.QingTingApi
+ height int
+ width int
+ statusbar teacomponents.StatusBar
+ helper teacomponents.Helper
+ banner teacommon.Component
+ view teacommon.View
+ downloader *download.Downloader
+ downloading bool
+ }
+
+ rendererCreate struct{}
+)
+
+func (r renderer) Init() tea.Cmd {
+ return tea.Batch(tea.EnterAltScreen, teacmd.CreateMsg(rendererCreate{}))
+}
+
+func (r renderer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ h, v := appStyle.GetFrameSize()
+ r.height = msg.Height - v
+ r.width = msg.Width - h
+
+ msg.Width = r.width
+ msg.Height = r.height
+
+ r.adjustDimensions(r.width, r.height)
+ case rendererCreate:
+ return r, tea.Batch(teacmd.Auth(r.api.RefreshToken, r.api.QingTingId), teacmd.RedirectHome())
+ case teacmd.UpdateStatusBarMsg:
+ r.updateStatusBar(msg.Title, msg.Msg)
+ return r, nil
+ case teacmd.AuthMsg:
+ return r, r.auth(msg.Token, msg.Id, msg.Callback)
+ case teacmd.AuthSuccessMsg:
+ return r, r.updateAuthResult(nil)
+ case teacmd.AuthFailMsg:
+ return r, r.updateAuthResult(msg.Err)
+ case teacmd.UpdateQuitKeyMsg:
+ r.helper.SetQQuitEnable(msg.Q)
+ r.helper.SetEscQuitEnable(msg.Esc)
+ return r, nil
+ case teacmd.HelpMsg:
+ r.helper.SetKeys(msg.Keys)
+ return r, nil
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, r.helper.HelpKey):
+ return r, r.helper.ToggleShowAll()
+ case key.Matches(msg, r.helper.ForceQuitKey):
+ return r, tea.Quit
+ case key.Matches(msg, r.helper.QQuitKey, r.helper.EscQuitKey):
+ if r.route != &teahome.HomeRoute {
+ return r, teacmd.RedirectHome()
+ }
+ return r, tea.Quit
+ }
+ case teacmd.RouteMsg:
+ return r, r.createView(msg.Route)
+ case teacmd.SearchMsg:
+ return r, r.handleSearch(msg.Keyword, msg.Type, msg.Page)
+
+ case teacmd.DownloadMsg:
+ return r, r.download(msg.Channel)
+ case teacmd.TickMsg:
+ return r, teacmd.Tick()
+ }
+
+ if r.view != nil {
+ return r, r.view.Handler(msg)
+ }
+ return r, nil
+}
+
+func (r renderer) View() string {
+ var (
+ sections []string
+ availableHeight = r.height
+ )
+
+ header := r.renderHeader()
+ availableHeight -= lipgloss.Height(header)
+ sections = append(sections, header)
+
+ statusBar := r.statusbar.Render()
+ availableHeight -= lipgloss.Height(statusBar)
+
+ helper := lipgloss.NewStyle().MarginBottom(1).Render(r.helper.Render())
+ availableHeight -= lipgloss.Height(helper)
+
+ sections = append(sections, r.renderContent(availableHeight))
+ sections = append(sections, helper)
+ sections = append(sections, statusBar)
+
+ return appStyle.Render(lipgloss.JoinVertical(lipgloss.Top, sections...))
+}
+
+func (r *renderer) renderContent(height int) string {
+ h := min(height, maxHeight)
+ if r.view != nil {
+ r.view.SetSize(r.width, h)
+ return lipgloss.NewStyle().Height(h).Render(r.view.Render())
+ }
+ return lipgloss.NewStyle().Height(min(height, maxHeight)).Render("hello world")
+}
+
+func (r *renderer) renderHeader() string {
+ var sections []string
+ sections = append(sections, r.banner.Render())
+ bread := []string{
+ teacommon.InfoStyle.Bold(true).Render(teahome.HomeRoute.Title),
+ }
+ if r.route != nil && r.route != &teahome.HomeRoute {
+ bread = []string{
+ teacommon.BlurredTextStyle(teahome.HomeRoute.Title),
+ teacommon.InfoStyle.Bold(true).Render(r.route.Title),
+ }
+ }
+ sections = append(sections, strings.Join(bread, teacommon.BlurredTextStyle(" > ")))
+ return lipgloss.NewStyle().
+ Width(r.width).
+ MarginBottom(1).
+ BorderBottom(true).
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(teacommon.SecondaryTextColor).
+ Render(lipgloss.JoinVertical(lipgloss.Top, sections...))
+}
+
+func (r *renderer) adjustDimensions(width int, height int) {
+ r.statusbar.SetSize(width, height)
+ r.helper.SetSize(width, height)
+}
+
+func (r *renderer) updateStatusBar(title, msg string) {
+ r.statusbar.UpdateColumnContent(0, title)
+ r.statusbar.UpdateColumnContent(1, msg)
+}
+
+func (r *renderer) resetStatusBar() {
+ r.updateStatusBar("QTG", "一个有趣的蜻蜓FM下载工具")
+}
+
+func (r *renderer) updateAuthInfo(info string) {
+ r.statusbar.UpdateColumnContent(2, info)
+}
+
+func (r *renderer) auth(token, id string, callback teacommon.Callback) tea.Cmd {
+ if token == "" || id == "" {
+ callback(fmt.Errorf("未配置Token"))
+ return teacmd.CreateMsg(teacmd.AuthFailMsg{
+ Err: fmt.Errorf("未配置Token"),
+ })
+ }
+
+ err := r.api.Auth(token, id)
+
+ if err != nil {
+ callback(err)
+ return teacmd.CreateMsg(teacmd.AuthFailMsg{Err: err})
+ }
+ r.cfg.Settings.RefreshToken = token
+ r.cfg.Settings.QingTingId = id
+ _ = config.WriteConfig(r.cfg)
+
+ callback(nil)
+ return teacmd.CreateMsg(teacmd.AuthSuccessMsg{})
+}
+
+func (r *renderer) updateAuthResult(err error) tea.Cmd {
+ var msg string
+ if err == nil {
+ if r.api.User.PrivateInfo.VipInfo.Vip {
+ msg = fmt.Sprintf("%s%s", r.api.User.Nickname, lipgloss.NewStyle().Italic(true).Bold(true).Foreground(teacommon.WarnColor).Render(" ✨"))
+ } else {
+ msg = r.api.User.Nickname
+ }
+ } else {
+ msg = err.Error()
+ }
+ r.updateAuthInfo(msg)
+ return nil
+}
+
+func (r *renderer) createView(route string) tea.Cmd {
+ switch route {
+ case teacommon.HomeRoute:
+ r.route = &teahome.HomeRoute
+ case teacommon.SearchRoute:
+ r.route = &teasearch.SearchRoute
+ case teacommon.AuthRoute:
+ r.route = &teaauth.AuthRoute
+ }
+ r.helper.ClearKeys()
+ if r.route != nil {
+ r.view = r.route.RenderComponent()
+ return tea.Batch(teacmd.NotificationRedirect(), r.view.Init())
+ }
+ return nil
+}
+
+func (r *renderer) handleSearch(keyword, searchType string, page int) tea.Cmd {
+ if keyword == "" {
+ return nil
+ }
+ result, err := r.api.Search(keyword, searchType, page)
+ return teacmd.HandleSearchResult(result, err)
+}
+
+func (r *renderer) download(channel teacommon.Channel) tea.Cmd {
+ r.downloader.DownloadChannel(channel)
+ return teacmd.Tick()
+}
+
+func (r *renderer) updateProgress(progress download.ChannelProgress) {
+ d := progress.Downloading.Cardinality()
+ f := progress.Finished.Cardinality()
+ t := progress.Total()
+ if d == 0 {
+ r.resetStatusBar()
+ return
+ }
+ current := progress.Downloading.ToSlice()[0]
+ channel, ok := progress.Channels[current.(string)]
+ if !ok {
+ r.resetStatusBar()
+ return
+ }
+
+ pt := channel.Programs.Total()
+
+ desc := ""
+ if channel.Programs.Finished != nil {
+ // fmt.Println(channel.Programs.Finished.ToSlice(), channel.Programs.Pending.ToSlice(), channel.Programs.Downloading.ToSlice())
+ pf := channel.Programs.Finished.Cardinality()
+ desc = fmt.Sprintf("%s(%d/%d)", channel.Name, pf, pt)
+ }
+
+ r.updateStatusBar(
+ fmt.Sprintf("下载中(%d/%d)", f, t),
+ desc,
+ )
+}
diff --git a/internal/ui/search/search.go b/internal/ui/search/search.go
new file mode 100644
index 0000000..d05ed85
--- /dev/null
+++ b/internal/ui/search/search.go
@@ -0,0 +1,295 @@
+package teasearch
+
+import (
+ "fmt"
+ "github.com/charmbracelet/bubbles/cursor"
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ teacmd "github.com/oustn/qtg/internal/ui/cmd"
+ teacommon "github.com/oustn/qtg/internal/ui/common"
+ teacomponents "github.com/oustn/qtg/internal/ui/components"
+ "math"
+)
+
+type title struct {
+ input textinput.Model
+ content string
+ focused bool
+}
+
+func (t *title) Render() string {
+ if !t.focused {
+ return t.content
+ }
+ return t.input.View()
+}
+
+func (t *title) Handler(msg tea.Msg) tea.Cmd {
+ return nil
+}
+
+func (t *title) SetSize(width, height int) {
+}
+
+func (t *title) SetTitle(title string) {
+ t.content = title
+}
+
+func (t *title) focus() {
+ t.focused = true
+ t.input.Focus()
+ t.input.SetValue("")
+}
+
+func (t *title) blur() {
+ t.focused = false
+ t.input.Blur()
+}
+
+var types = []string{"综合排序", "热度最高", "最近更新"}
+var typeKeys = []string{"0", "1", "2"}
+
+type searchKey struct {
+ next key.Binding
+ prev key.Binding
+ accept key.Binding
+}
+
+type searchItem struct {
+ id string
+ title string
+ description string
+}
+
+func (s searchItem) Title() string {
+ return s.title
+}
+
+func (s searchItem) Description() string {
+ return s.description
+}
+
+func (s searchItem) FilterValue() string {
+ return s.title
+}
+
+var SearchRoute = teacommon.Route{
+ Title: "🔍 搜索",
+ Name: teacommon.SearchRoute,
+ RenderComponent: func() teacommon.View {
+ items := make([]teacomponents.ListItem, 0)
+
+ searchTitle := title{
+ input: textinput.New(),
+ }
+
+ searchTitle.input.Placeholder = "搜索..."
+ searchTitle.input.Width = 30
+ searchTitle.input.Prompt = "🔍 "
+ searchTitle.input.Cursor.Style = teacommon.FocusedStyle
+
+ l := teacomponents.NewList(items)
+ l.Title = &searchTitle
+ l.EmptyTip = fmt.Sprintf(`%s %s %s`,
+ teacommon.BlurredTextStyle("听点有趣的~ 键入"),
+ teacommon.FocusedStyle.Bold(true).Render("/"),
+ teacommon.BlurredTextStyle("开始搜索"),
+ )
+
+ return &search{
+ list: l,
+ title: &searchTitle,
+ keys: searchKey{
+ next: key.NewBinding(
+ key.WithKeys("ctrl+l"),
+ key.WithHelp("ctrl+l", "搜索下一页"),
+ ),
+ prev: key.NewBinding(
+ key.WithKeys("ctrl+h"),
+ key.WithHelp("ctrl+h", "搜索上一页"),
+ ),
+ accept: key.NewBinding(
+ key.WithKeys("ctrl+d"),
+ key.WithHelp("ctrl+d", "开始下载"),
+ ),
+ },
+ }
+ },
+}
+
+const pageSize = 30
+
+type search struct {
+ list *teacomponents.List
+ title *title
+ focused bool
+ keys searchKey
+ keyword string
+ page int
+ searchType string
+ total int
+}
+
+func (s *search) Init() tea.Cmd {
+ s.list.UnbindFilterKeys()
+ return tea.Batch(
+ teacmd.UpdateHelpKeys([]*key.Binding{
+ &s.list.ListKeyMap.Filter,
+ &s.list.ListKeyMap.ClearFilter,
+ &s.list.ListKeyMap.CancelWhileFiltering,
+ &s.list.ListKeyMap.AcceptWhileFiltering,
+ &s.list.ListKeyMap.CursorUp,
+ &s.list.ListKeyMap.CursorDown,
+ &s.list.ListKeyMap.PrevPage,
+ &s.list.ListKeyMap.NextPage,
+ &s.list.ListKeyMap.GoToStart,
+ &s.list.ListKeyMap.GoToEnd,
+ &s.keys.prev,
+ &s.keys.next,
+ &s.keys.accept,
+ }),
+ )
+}
+
+func (s *search) Render() string {
+ return s.list.Render()
+}
+
+func (s *search) Handler(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, s.list.ListKeyMap.Filter):
+ return s.focusSearchInput()
+ case key.Matches(msg, s.list.ListKeyMap.CancelWhileFiltering):
+ return s.blurSearchInput()
+ case key.Matches(msg, s.list.ListKeyMap.AcceptWhileFiltering):
+ return s.handleAccept()
+ case key.Matches(msg, s.keys.next):
+ return s.handleSearchNext()
+ case key.Matches(msg, s.keys.prev):
+ return s.handleSearchPrev()
+ case key.Matches(msg, s.keys.accept):
+ return s.handleDownload()
+ }
+ case teacmd.SearchResultMsg:
+ return s.HandleSearchResult(msg.Result, msg.Error)
+
+ }
+ if s.focused {
+ return s.updateInput(msg)
+ }
+ return s.list.Handler(msg)
+}
+
+func (s *search) SetSize(width, height int) {
+ s.list.SetSize(width, height)
+}
+
+func (s *search) focusSearchInput() tea.Cmd {
+ s.title.focus()
+ s.list.BindFilterKeys()
+ if s.focused {
+ s.title.input.SetValue("")
+ }
+ s.focused = true
+ return tea.Batch(cursor.Blink, teacmd.DisableBothQuit())
+}
+
+func (s *search) updateInput(msg tea.Msg) tea.Cmd {
+ var cmd tea.Cmd
+ s.title.input, cmd = s.title.input.Update(msg)
+ return cmd
+}
+
+func (s *search) blurSearchInput() tea.Cmd {
+ if !s.focused {
+ return nil
+ }
+ s.focused = false
+ s.title.blur()
+ //s.title.input.SetValue("")
+ s.list.UnbindFilterKeys()
+ return teacmd.EnableBothQuit()
+}
+
+func (s *search) handleAccept() tea.Cmd {
+ if s.focused {
+ return s.handleSearch()
+ }
+ return s.handleSelect()
+}
+
+func (s *search) handleSearch() tea.Cmd {
+ keyword := s.title.input.Value()
+ if keyword == "" {
+ return nil
+ }
+ s.keyword = keyword
+ s.searchType = typeKeys[0]
+ s.page = 1
+ cmd := s.blurSearchInput()
+ s.list.Loading(true)
+ return tea.Batch(cmd, s.search())
+}
+
+func (s *search) search() tea.Cmd {
+ s.list.Loading(true)
+ return teacmd.HandleSearch(s.keyword, s.searchType, s.page)
+}
+
+func (s *search) handleSelect() tea.Cmd {
+ return nil
+}
+
+func (s *search) HandleSearchResult(result teacommon.SearchResult, err error) tea.Cmd {
+ var items []teacomponents.ListItem
+
+ s.list.Loading(false)
+
+ if err == nil {
+ for _, channel := range result.Data {
+ items = append(items, channel)
+ }
+ }
+
+ s.list.SetEntity(items)
+ s.total = result.Total
+ s.page = result.Page
+
+ s.title.SetTitle(teacommon.BlurredTextStyle(fmt.Sprintf(`"%s"的搜索结果,共%d(%d)条 (%d/%.0f页)`,
+ result.Keyword,
+ result.Total,
+ result.PageSize,
+ result.Page,
+ math.Ceil(float64(result.Total)/float64(result.PageSize)),
+ )))
+ return nil
+}
+
+func (s *search) handleSearchNext() tea.Cmd {
+ if s.focused {
+ return nil
+ }
+ totalPage := int(math.Ceil(float64(s.total) / float64(pageSize)))
+ if s.page >= totalPage {
+ return nil
+ }
+ s.page += 1
+ return s.search()
+}
+
+func (s *search) handleSearchPrev() tea.Cmd {
+ if s.focused || s.page == 1 {
+ return nil
+ }
+ s.page -= 1
+ s.list.Loading(true)
+ return s.search()
+}
+
+func (s *search) handleDownload() tea.Cmd {
+ item := s.list.GetSelect()
+ return teacmd.StartDownload(item.(teacommon.Channel))
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d119716
--- /dev/null
+++ b/main.go
@@ -0,0 +1,7 @@
+package main
+
+import "github.com/oustn/qtg/cmd"
+
+func main() {
+ cmd.Execute()
+}