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() +}